diff --git a/CHANGELOG.md b/CHANGELOG.md index 500cb41634..fc537909d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `computers/` on the workspace volume (below). Files saved before the upgrade stay at the root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. +### The audit log reads like a log + +Admin → Audit fills the window and runs the table across the whole pane, with the title, the views +and the search held in place while only the rows scroll. Each row shows the full date and time and a +mark for what happened, with blocked rows tinted red and actions that did not happen tinted amber. +The newest come first, and a press on When turns the order round. A search box and Outcome, Bot and +Started by buttons narrow the events already loaded, and the count says how many of them match. A +row about a Bot (a message routed to it, say) now names it under Bot. + ### Each Bot on a shared computer has its own files A computer that serves many Bots kept one folder of files for all of them, so a note one Bot saved diff --git a/app/package.json b/app/package.json index f741d78e53..52779289f1 100644 --- a/app/package.json +++ b/app/package.json @@ -27,6 +27,7 @@ "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.27", + "@tanstack/react-table": "^9.3.1", "better-auth": "^1.6.27", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", diff --git a/app/src/components/audit/audit-cells.tsx b/app/src/components/audit/audit-cells.tsx new file mode 100644 index 0000000000..8bdfe0ffad --- /dev/null +++ b/app/src/components/audit/audit-cells.tsx @@ -0,0 +1,435 @@ +import { type AuditOutcome, outcomeOf } from "@/lib/audit/outcome"; +import { silenceOf } from "@/lib/audit/silence"; + +/** One row as the API returns it. */ +export type AuditEvent = { + id: string; + actorUserId: string | null; + /** Absent on a deployment that has not migrated yet. */ + initiatorKind?: string; + initiatorId?: string | null; + eventType: string; + targetType: string; + targetId: string | null; + payload: Record; + createdAt: string; +}; + +type NameFor = (botId: string) => string; + +/** + * Each cell of the Audit table, and the same thing as plain text for the search box. + * + * The text versions read the same fields in the same order as the cells, so what a person can see + * on a row is what they can find it by. + */ + +const WHEN = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}); + +export function whenText(event: AuditEvent): string { + return WHEN.format(new Date(event.createdAt)); +} + +/** Strip the internal computer tool namespace for display. */ +export function whatText(event: AuditEvent): string { + const action = event.payload?.action; + return typeof action === "string" + ? action.replace("computer_", "") + : event.eventType; +} + +type Element = { role?: string; name?: string } | string | undefined; + +/** The row's subject, the part of "On" that is not the page host. */ +export function subjectText(event: AuditEvent): string { + const payload = event.payload ?? {}; + const element = payload.element as Element; + if (event.eventType === "mcp.tools_discovered") + return typeof payload.offered === "number" && + typeof payload.granted === "number" + ? `${payload.offered} of ${payload.granted} tools` + : ""; + if (NAMED_TARGETS.has(event.targetType) && event.targetId) + return typeof payload.function === "string" + ? `${event.targetId} · ${payload.function}` + : event.targetId; + if (typeof payload.file === "string") return payload.file; + if (typeof payload.command === "string") return payload.command; + if (typeof element === "object" && element?.name) + return element.role ? `${element.name} (${element.role})` : element.name; + if (typeof element === "string") return element; + return ""; +} + +/** The page host under the subject, meaningful only for browser actions. */ +export function hostText(event: AuditEvent): string { + const payload = event.payload ?? {}; + return typeof payload.file !== "string" && + typeof payload.command !== "string" && + typeof payload.page === "string" && + payload.page + ? hostOf(payload.page) + : ""; +} + +/** + * The Bot a row is about. Actions a Bot took carry it in the payload; events done TO a Bot (routed + * to it, started, handed work, paused) record it as the target instead, and are just as much its row. + */ +export function botIdOf(event: AuditEvent): string | null { + if (typeof event.payload?.bot === "string") return event.payload.bot; + return event.targetType === "agent" && event.targetId ? event.targetId : null; +} + +export function botText(event: AuditEvent, nameFor: NameFor): string { + const bot = botIdOf(event); + return bot ? nameFor(bot) : ""; +} + +export function startedByText(event: AuditEvent, nameFor: NameFor): string { + switch (event.initiatorKind) { + case "routine": + return "A routine"; + case "handoff": + return event.initiatorId + ? `Handed on by ${nameFor(event.initiatorId)}` + : "Handed on"; + case "memory": + return "A memory source"; + case "responsibility": + return "A responsibility"; + case "deployment": + return "This deployment"; + default: + return "A person"; + } +} + +export function decisionText(event: AuditEvent): string { + const payload = event.payload ?? {}; + const outcome = outcomeOf(event.eventType); + /* + * Three different things, and the difference is what somebody comes to this row to find out. + * + * A person naming a coworker, the router matching one, and the router giving up and using the + * default are not the same event, and one label covering all three would make the row worth less + * than the reason line under it. Nothing here is a refusal, so none of them take the refusal + * colour. + */ + const routed = + event.eventType === "channel.routed" + ? payload.viaMention === true + ? "The person chose this Bot" + : payload.fallback === true + ? "Sent to the default Bot" + : "Sent to the Bot it was meant for" + : null; + return ( + routed ?? + DECISIONS[event.eventType] ?? + (outcome === "refused" + ? "Blocked" + : outcome === "did-not-happen" + ? "Did not happen" + : "Allowed") + ); +} + +/** Every reason line under the decision, in the order the cell draws them. */ +export function decisionDetails(event: AuditEvent): string[] { + const payload = event.payload ?? {}; + const decision = (payload.decision ?? {}) as { + allowed?: boolean; + mode?: string; + rule?: string | null; + carriedOut?: boolean; + }; + const failed = outcomeOf(event.eventType) === "did-not-happen"; + const lines: string[] = []; + // Refusal reasons mirror the conversation-facing reason. + if ( + (event.eventType === "component.refused" || + event.eventType === "component.function_refused" || + event.eventType === "mcp.call_rejected") && + typeof payload.reason === "string" + ) + lines.push(payload.reason); + /* + * Why this run was offered what it was, which is the only part of a discovery row that cannot be + * worked out from the numbers. "Nothing declared" and "selector unavailable" both offer everything + * and mean entirely different things about the deployment. + */ + if ( + event.eventType === "mcp.tools_discovered" && + typeof payload.reason === "string" + ) + lines.push( + `${DISCOVERY_REASONS[payload.reason] ?? payload.reason}${ + Array.isArray(payload.skills) && payload.skills.length > 0 + ? `: ${payload.skills.join(", ")}` + : "" + }`, + ); + if ( + event.eventType === "mcp.callback_refused" && + typeof payload.refusal === "string" + ) + lines.push(payload.refusal); + /* + * Why the conversation went where it went, which is the whole reason the row is written. Without + * it a routing row says "Allowed" and names nobody, which is indistinguishable from a row that + * failed to write. + */ + if ( + event.eventType === "channel.routed" && + typeof payload.reason === "string" + ) + lines.push(payload.reason); + if (event.eventType === "bot.declined" && typeof payload.reason === "string") + lines.push(`${payload.reason}, reported by the Bot itself`); + if (failed && typeof payload.failure === "string") + lines.push(payload.failure); + /* + * The two numbers the stall row is worth reading for. Without them every stalled turn looks the + * same, and the difference between an endpoint that dies halfway through an answer and one that + * never begins is the difference between a slow Bot and a dead one. + */ + const silence = + event.eventType === "agent.stream_stalled" ? silenceOf(payload) : null; + if (silence) lines.push(silence); + // Show concrete policy rules, but suppress the uninformative default `true` allow rule. + if (decision.rule && decision.rule !== "true") lines.push(decision.rule); + if ( + decision.mode === "dry-run" && + decision.allowed === false && + decision.carriedOut + ) + lines.push("dry-run: recorded, not enforced"); + return lines; +} + +/** Everything on a row as one lower-cased string, for the search box. */ +export function searchTextOf(event: AuditEvent, nameFor: NameFor): string { + return [ + whatText(event), + subjectText(event), + hostText(event), + botText(event, nameFor), + startedByText(event, nameFor), + decisionText(event), + ...decisionDetails(event), + ] + .join(" ") + .toLowerCase(); +} + +export function OnCell({ event }: { event: AuditEvent }) { + const payload = event.payload ?? {}; + const element = payload.element as Element; + const subject = subjectText(event); + const host = hostText(event); + /* + * Named targets, tool counts, files and commands are identifiers and read in mono; a page element + * is a label and does not. + */ + const mono = + event.eventType === "mcp.tools_discovered" || + (NAMED_TARGETS.has(event.targetType) && !!event.targetId) || + typeof payload.file === "string" || + typeof payload.command === "string"; + return ( + <> + {!subject ? ( + - + ) : mono ? ( + {subject} + ) : typeof element === "object" && element?.name ? ( + + {element.name} + {element.role ? ( + ({element.role}) + ) : null} + + ) : ( + {subject} + )} + {host ? ( +
{host}
+ ) : null} + + ); +} + +export function BotCell({ + event, + nameFor, +}: { + event: AuditEvent; + nameFor: NameFor; +}) { + const bot = botIdOf(event); + // Keep the immutable bot id available even when names collide. + return bot ? ( + + {nameFor(bot)} + + ) : ( + - + ); +} + +export function StartedByCell({ + event, + nameFor, +}: { + event: AuditEvent; + nameFor: NameFor; +}) { + // Anything that ran with nobody watching is the colour of a warning; a person or the deployment is not. + const unattended = + event.initiatorKind === "routine" || + event.initiatorKind === "handoff" || + event.initiatorKind === "responsibility" || + event.initiatorKind === "memory"; + return ( + + {startedByText(event, nameFor)} + + ); +} + +export function DecisionCell({ event }: { event: AuditEvent }) { + const outcome = outcomeOf(event.eventType); + return ( + <> + + {decisionText(event)} + + {decisionDetails(event).map((line) => ( + /* + * A width rather than a max-width, because the table lays itself out from its content and a + * max-width on a block inside a cell does not constrain that. A router's reason is a + * sentence a model wrote, and left unbounded in the last column it pushes the table wider + * than the pane. + */ +
+ {line} +
+ ))} + + ); +} + +/** The square at the start of each row: what happened, at a glance down the log. */ +export const OUTCOME_MARK: Record = { + refused: "bg-destructive", + "did-not-happen": "bg-amber-500", + allowed: "bg-muted-foreground/20", +}; + +/** The faint tint on rows worth stopping at. Allowed rows stay plain. */ +export const OUTCOME_ROW: Record = { + refused: "bg-destructive/5 hover:bg-destructive/10", + "did-not-happen": "bg-amber-500/5 hover:bg-amber-500/10", + allowed: "", +}; + +/** + * Why a run was offered the tools it was, in words rather than in the slug the server writes. + * + * Every one of these looks the same from outside: the Bot was handed some tools. The distinction is + * the difference between a deployment that narrowed on purpose, one that has never declared a skill, + * and one whose selector could not be reached, and only the last is a fault. + */ +const DISCOVERY_REASONS: Record = { + "under-floor": "Few enough tools to offer them all", + "nothing-declared": "No skill declares any of these tools", + unavailable: "Couldn't choose, so all were offered", + "nothing-chosen": "No skill applied, so all were offered", + selected: "Chosen by skill", +}; + +/** Target types whose id is a name worth putting on screen; anything else falls through. */ +const NAMED_TARGETS = new Set([ + "component", + "mcp_tool", + "mcp_server", + "skill", + "credential", +]); + +const DECISIONS: Record = { + "bot.declined": "The Bot declined", + // Not a refusal, so not the refusal colour: nothing was blocked. The Bot was asked and never + // answered, which is the same complaint as an action that was allowed and then did not happen. + "agent.stream_stalled": "The Bot stopped responding", + "computer.policy_loaded": "Boundary at start-up", + "computer.isolation_loaded": "Isolation at start-up", + "computer.control_taken": "A person took control", + "computer.control_released": "Control went back to the Bot", + "computer.help_requested": "The Bot asked for help", + "computer.secret_requested": "The Bot asked for a secret", + "computer.secret_supplied": "A person supplied a secret", + "computer.reset": "The computer was reset", + "computer.updated": "The computer was updated", + "computer.stopped": "A person pressed stop", + + "component.granted": "Granted to this Bot", + "component.revoked": "Taken away from this Bot", + "component.published": "Published. Every Bot can use it", + "component.unpublished": "Unpublished. No Bot can use it", + "component.draft_saved": "Draft saved, not yet published", + "component.refused": "Refused", + "component.function_granted": "Can read this", + "component.function_revoked": "Can no longer read this", + "component.function_called": "Read real data", + "component.function_refused": "Refused", + // A function failure is execution failure, not a policy refusal. + "component.function_failed": "Couldn't read it", + + // Not a call and not a decision: the tools this run was allowed to see. Worded so nobody reads it + // as permission, which it is not — everything named was already granted. + "mcp.tools_discovered": "Tools offered for one run", + "mcp.call_succeeded": "Called on this Bot's behalf", + "mcp.call_rejected": "Blocked", + "mcp.call_failed": "The server didn't answer", + // Not "Blocked": nothing about the Bot was judged, because nothing proved which Bot it was. + "mcp.callback_refused": "Couldn't confirm which Bot it was", + + "configuration.changed": "Configuration changed", + "credential.created": "Credential saved", +}; + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/app/src/components/audit/audit-table.tsx b/app/src/components/audit/audit-table.tsx new file mode 100644 index 0000000000..bf69b1f756 --- /dev/null +++ b/app/src/components/audit/audit-table.tsx @@ -0,0 +1,400 @@ +import { + IconArrowDown, + IconArrowUp, + IconCirclePlus, + IconSearch, +} from "@tabler/icons-react"; +import { + type ColumnDef, + type ColumnFiltersState, + columnFilteringFeature, + createFilteredRowModel, + createSortedRowModel, + globalFilteringFeature, + rowSortingFeature, + type SortingState, + tableFeatures, + useTable, +} from "@tanstack/react-table"; +import type * as React from "react"; +import { useMemo, useState } from "react"; +import { PageEmpty } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { type AuditOutcome, outcomeOf } from "@/lib/audit/outcome"; +import { cn } from "@/lib/utils"; +import { + type AuditEvent, + BotCell, + botText, + DecisionCell, + OnCell, + OUTCOME_MARK, + OUTCOME_ROW, + StartedByCell, + searchTextOf, + startedByText, + whatText, + whenText, +} from "./audit-cells"; + +/** + * The audit log as a table: newest first, sortable by time, searchable across everything a row + * shows. Sorting and search act on the events already loaded; the saved views above it are what + * ask the server for a different set. + */ +const features = tableFeatures({ + columnFilteringFeature, + globalFilteringFeature, + rowSortingFeature, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), +}); + +type NameFor = (botId: string) => string; + +/** Only the hidden `search` column takes part in search; it holds every visible word of the row. */ +const SEARCHABLE = new Set(["search"]); + +const INITIAL_SORT: SortingState = [{ id: "when", desc: true }]; + +const OUTCOME_LABEL: Record = { + allowed: "Allowed", + refused: "Blocked", + "did-not-happen": "Did not happen", +}; + +/** + * The columns a toolbar button narrows by, each matching any of the values ticked. Like search, they + * act on the rows already loaded. + */ +const FACETS = [ + { id: "mark", title: "Outcome" }, + { id: "bot", title: "Bot" }, + { id: "startedBy", title: "Started by" }, +] as const; + +export function AuditTable({ + events, + nameFor, + query, + toolbar, +}: { + events: AuditEvent[]; + nameFor: NameFor; + query: string; + /** + * 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. + */ + toolbar: (parts: { + count: string; + filters: React.ReactNode; + }) => React.ReactNode; +}) { + const [sorting, setSorting] = useState(INITIAL_SORT); + const [columnFilters, setColumnFilters] = useState([]); + const columns = useMemo(() => auditColumns(nameFor), [nameFor]); + const table = useTable({ + features, + columns, + data: events, + getRowId: (event) => event.id, + state: { sorting, globalFilter: query, columnFilters }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + enableSortingRemoval: false, + globalFilterFn: (row, _columnId, value: string) => + row + .getValue("search") + .includes(String(value).trim().toLowerCase()), + getColumnCanGlobalFilter: (column) => SEARCHABLE.has(column.id), + }); + const rows = table.getRowModel().rows; + + return ( + <> + {toolbar({ + count: countLabel(rows.length, events.length), + filters: FACETS.map((facet) => { + const column = table.getColumn(facet.id); + const selected = (column?.getFilterValue() as string[]) ?? []; + return ( + + column?.setFilterValue(next.length > 0 ? next : undefined) + } + options={tally(events, (event) => + String(column?.accessorFn?.(event, 0) ?? ""), + )} + selected={selected} + title={facet.title} + /> + ); + }), + })} + 0 ? "min-h-0 flex-1" : undefined} + > + + {table.getHeaderGroups().map((group) => ( + + {group.headers + .filter((header) => header.column.id !== "search") + .map((header) => ( + + {header.isPlaceholder ? null : ( + + )} + + ))} + + ))} + + + {rows.map((row) => { + const outcome = outcomeOf(row.original.eventType); + return ( + + {row + .getAllCells() + .filter((cell) => cell.column.id !== "search") + .map((cell) => ( + + + + ))} + + ); + })} + +
+ {events.length > 0 && rows.length === 0 ? ( +
+ + Nothing loaded matches this search and these filters. + +
+ ) : null} + + ); +} + +/** A row passes when its value is one of those ticked. */ +function anyOf( + row: { getValue: (id: string) => T }, + columnId: string, + value: string[], +): boolean { + return value.includes(row.getValue(columnId)); +} + +/** Each value a column holds across the loaded events, with how many rows have it, most first. */ +function tally( + events: AuditEvent[], + valueFor: (event: AuditEvent) => string, +): { value: string; count: number }[] { + const counts = new Map(); + for (const event of events) { + const value = valueFor(event); + counts.set(value, (counts.get(value) ?? 0) + 1); + } + return [...counts] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); +} + +/** A dashed button that opens a list of a column's values to tick, as in a data table's toolbar. */ +function Facet({ + title, + options, + selected, + onChange, +}: { + title: string; + options: { value: string; count: number }[]; + selected: string[]; + onChange: (next: string[]) => void; +}) { + return ( + + + } + > + + {title} + {selected.length > 0 ? ( + + {selected.length === 1 + ? selected[0] + : `${selected.length} selected`} + + ) : null} + + + {options.map((option) => ( + + onChange( + next + ? [...selected, option.value] + : selected.filter((value) => value !== option.value), + ) + } + > + {option.value} + + {option.count} + + + ))} + {selected.length > 0 ? ( + <> + + onChange([])}> + Clear filter + + + ) : null} + + + ); +} + +/** 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}`; +} + +function auditColumns( + nameFor: NameFor, +): ColumnDef[] { + return [ + { + id: "mark", + accessorFn: (event) => OUTCOME_LABEL[outcomeOf(event.eventType)], + filterFn: anyOf, + header: () => Outcome, + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + id: "when", + accessorFn: (event) => Date.parse(event.createdAt), + header: ({ column }) => { + const sorted = column.getIsSorted(); + return ( + + ); + }, + cell: ({ row }) => whenText(row.original), + sortFn: (a, b, id) => a.getValue(id) - b.getValue(id), + }, + { + id: "what", + accessorFn: (event) => whatText(event), + header: "What", + cell: ({ row }) => whatText(row.original), + enableSorting: false, + }, + { + id: "on", + header: "On", + cell: ({ row }) => , + enableSorting: false, + }, + { + id: "bot", + accessorFn: (event) => botText(event, nameFor) || "None", + filterFn: anyOf, + header: "Bot", + cell: ({ row }) => , + enableSorting: false, + }, + { + id: "startedBy", + accessorFn: (event) => startedByText(event, nameFor), + filterFn: anyOf, + header: "Started by", + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + id: "decision", + header: "Decision", + cell: ({ row }) => , + enableSorting: false, + }, + { + id: "search", + accessorFn: (event) => searchTextOf(event, nameFor), + header: () => null, + cell: () => null, + enableSorting: false, + }, + ]; +} diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 519c963c26..7fbca5c2f3 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -35,15 +35,13 @@ import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle"; * and its control side by side. Sentences are held to `max-w-prose` (around 65 characters) inside it, * which is where reading stays comfortable, so the column widening does not stretch them. * - * `wide` exists for one page. An audit log is a table you scan, and forcing it into the prose column - * would wrap every row. It is not a licence for anything else to be wide. + * `full` exists for one page. An audit log is a table you scan, and forcing it into the prose column + * would wrap every row. The page is exactly the window's height and does not scroll: the title sits + * at the pane's left edge, lined up with the table's first column rather than centred above a table + * that is not, and the children fill what is left, so the table can be the one thing that scrolls. + * It is not a licence for anything else to be wide. */ -type ShellWidth = "prose" | "wide"; - -const WIDTHS: Record = { - prose: "max-w-3xl", - wide: "max-w-5xl", -}; +type ShellWidth = "prose" | "full"; export function PageShell({ action, @@ -84,22 +82,56 @@ export function PageShell({ const showToggle = useSidebarToggleVisible(); const bar = showToggle || !!backButton; + const header = ( +
+
+

{title}

+ {action} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); + + const barRow = bar ? ( +
+ {showToggle ? : null} + {!!backButton && ( + + )} +
+ ) : null; + + if (width === "full") { + return ( + /* + * One viewport, never scrolls. `min-w-0` lets a table wider than the pane scroll inside its own + * container instead of pushing the page sideways. + */ +
+ {barRow} +
+ {header} +
+
+ {children} +
+
+ ); + } + return ( <> - {bar ? ( -
- {showToggle ? : null} - {!!backButton && ( - - )} -
- ) : null} + {barRow} {/* * The scroller, and it has to live here rather than in either shell, because the two shells * this frame is used under disagree about who scrolls. @@ -135,21 +167,11 @@ export function PageShell({ "[&>header+*]:mt-8 [&>header+nav]:mt-6 [&>header+nav+*]:mt-8", // Without a bar above it the heading keeps the full original space. bar ? "pt-8" : "pt-12", - WIDTHS[width], + "max-w-3xl", className, )} > -
-
-

{title}

- {action} -
- {description ? ( -

- {description} -

- ) : null} -
+ {header} {children} diff --git a/app/src/components/ui/table.tsx b/app/src/components/ui/table.tsx new file mode 100644 index 0000000000..dfb6ea52d7 --- /dev/null +++ b/app/src/components/ui/table.tsx @@ -0,0 +1,93 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** + * A data table's parts, for a log or report read across the full pane. + * + * No card around it. The container is the scroller: given a height (`containerClassName`), the + * rows scroll under a header that stays put; without one, a table wider than the pane scrolls sideways. + */ +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<"table"> & { containerClassName?: string }) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ); +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + - - - - - - - - ); -} - -/** - * Target types whose id is a name worth putting on screen. - * - * Anything else falls through to the element or file subject. - */ -/** - * Why a run was offered the tools it was, in words rather than in the slug the server writes. - * - * Every one of these looks the same from outside: the Bot was handed some tools. The distinction is - * the difference between a deployment that narrowed on purpose, one that has never declared a skill, - * and one whose selector could not be reached, and only the last is a fault. - */ -const DISCOVERY_REASONS: Record = { - "under-floor": "Few enough tools to offer them all", - "nothing-declared": "No skill declares any of these tools", - unavailable: "Couldn't choose, so all were offered", - "nothing-chosen": "No skill applied, so all were offered", - selected: "Chosen by skill", -}; - -const NAMED_TARGETS = new Set([ - "component", - "mcp_tool", - "mcp_server", - "skill", - "credential", -]); - -const DECISIONS: Record = { - "bot.declined": "The Bot declined", - // Not a refusal, so not the refusal colour: nothing was blocked. The Bot was asked and never - // answered, which is the same complaint as an action that was allowed and then did not happen. - "agent.stream_stalled": "The Bot stopped responding", - "computer.policy_loaded": "Boundary at start-up", - "computer.isolation_loaded": "Isolation at start-up", - "computer.control_taken": "A person took control", - "computer.control_released": "Control went back to the Bot", - "computer.help_requested": "The Bot asked for help", - "computer.secret_requested": "The Bot asked for a secret", - "computer.secret_supplied": "A person supplied a secret", - "computer.reset": "The computer was reset", - "computer.updated": "The computer was updated", - "computer.stopped": "A person pressed stop", - - "component.granted": "Granted to this Bot", - "component.revoked": "Taken away from this Bot", - "component.published": "Published. Every Bot can use it", - "component.unpublished": "Unpublished. No Bot can use it", - "component.draft_saved": "Draft saved, not yet published", - "component.refused": "Refused", - "component.function_granted": "Can read this", - "component.function_revoked": "Can no longer read this", - "component.function_called": "Read real data", - "component.function_refused": "Refused", - // A function failure is execution failure, not a policy refusal. - "component.function_failed": "Couldn't read it", - - // Not a call and not a decision: the tools this run was allowed to see. Worded so nobody reads it - // as permission, which it is not — everything named was already granted. - "mcp.tools_discovered": "Tools offered for one run", - "mcp.call_succeeded": "Called on this Bot's behalf", - "mcp.call_rejected": "Blocked", - "mcp.call_failed": "The server didn't answer", - // Not "Blocked": nothing about the Bot was judged, because nothing proved which Bot it was. - "mcp.callback_refused": "Couldn't confirm which Bot it was", - - "configuration.changed": "Configuration changed", - "credential.created": "Credential saved", -}; - -function hostOf(url: string): string { - try { - return new URL(url).host; - } catch { - return url; - } -} diff --git a/app/src/routes/_authed/admin/route.tsx b/app/src/routes/_authed/admin/route.tsx index 94acef9229..4100a4f784 100644 --- a/app/src/routes/_authed/admin/route.tsx +++ b/app/src/routes/_authed/admin/route.tsx @@ -19,7 +19,7 @@ function RouteComponent() { return ( -
+
diff --git a/app/tests/audit-table.test.tsx b/app/tests/audit-table.test.tsx new file mode 100644 index 0000000000..a4995f99cd --- /dev/null +++ b/app/tests/audit-table.test.tsx @@ -0,0 +1,162 @@ +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, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + type AuditEvent, + botText, + subjectText, +} from "@/components/audit/audit-cells"; +import { agentKeys } from "@/lib/agents/queries"; +import { auditKeys } from "@/lib/audit/queries"; +import { Route as AuditRoute } from "@/routes/_authed/admin/audit"; + +/** + * Admin → Audit as a log viewer: one window tall, the table across the whole pane and the only thing + * that scrolls, searchable and sortable by time, each row coloured by what + * happened. + */ +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => cleanup()); +// The filter test leaves a menu open; let React finish closing it before `window` goes away. +afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +function event( + id: string, + eventType: string, + at: string, + payload: Record = {}, +): Record { + return { + id, + actorUserId: null, + initiatorKind: "person", + initiatorId: null, + eventType, + targetType: "computer", + targetId: "bot-a", + payload: { bot: "bot-a", ...payload }, + createdAt: at, + }; +} + +const ROWS = [ + event("e1", "computer.action_allowed", "2026-10-27T09:00:00Z", { + action: "computer_click", + element: { name: "Save deal", role: "button" }, + }), + event("e2", "computer.action_refused", "2026-10-27T10:00:00Z", { + action: "computer_navigate", + element: "https://evil.test", + }), + event("e3", "computer.action_failed", "2026-10-27T08:00:00Z", { + action: "computer_type", + element: { name: "Notes", role: "textbox" }, + }), +]; + +async function renderAudit() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + queryClient.setQueryData([...auditKeys.all, ""], { events: ROWS }); + queryClient.setQueryData(agentKeys.list(false), []); + const router = createRouter({ + routeTree: createRootRoute({ component: AuditRoute.options.component }), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const view = render( + + + , + ); + await view.findByText("Decision"); + return view; +} + +const whats = (container: HTMLElement) => + [...container.querySelectorAll("tbody tr")].map( + (row) => row.querySelector("[data-column=what]")?.textContent, + ); + +test("the page is one viewport tall and only the table scrolls", async () => { + const view = await renderAudit(); + const heading = view.getByRole("heading", { name: "Audit" }); + const page = heading.closest(".h-dvh"); + expect(page).not.toBeNull(); + expect(page?.className).toContain("overflow-hidden"); + expect(heading.closest(".max-w-3xl")).toBeNull(); + const scroller = view.container.querySelector( + "[data-slot=table-container]", + ) as HTMLElement; + expect(scroller.className).toContain("overflow-auto"); + expect(scroller.className).toContain("flex-1"); + expect(scroller.contains(heading)).toBe(false); +}); + +test("newest first, and a press on When turns it oldest first", async () => { + const view = await renderAudit(); + expect(whats(view.container)).toEqual(["navigate", "click", "type"]); + await userEvent + .setup({ document }) + .click(view.getByRole("button", { name: /When/ })); + await waitFor(() => + expect(whats(view.container)).toEqual(["type", "click", "navigate"]), + ); +}); + +test("search narrows the rows already loaded, and says how many match", async () => { + const view = await renderAudit(); + await userEvent + .setup({ document }) + .type(view.getByRole("searchbox", { name: "Search events" }), "save deal"); + await waitFor(() => expect(whats(view.container)).toEqual(["click"])); + expect(view.getByText("1 of 3 events")).toBeDefined(); +}); + +test("each row carries what happened, for its colour", async () => { + const view = await renderAudit(); + const outcomes = [...view.container.querySelectorAll("tbody tr")].map((row) => + row.getAttribute("data-outcome"), + ); + expect(outcomes).toEqual(["refused", "allowed", "did-not-happen"]); +}); + +test("a row about a Bot names it under Bot, not under On", () => { + const routed = { + ...event("e4", "channel.routed", "2026-10-27T11:00:00Z"), + targetType: "agent", + targetId: "general-assistant", + payload: { + chosen: "general-assistant", + reason: "named by the person asking", + }, + } as unknown as AuditEvent; + const nameFor = (id: string) => + id === "general-assistant" ? "General Assistant" : id; + expect(botText(routed, nameFor)).toBe("General Assistant"); + expect(subjectText(routed)).toBe(""); +}); + +test("the Outcome button narrows the rows to the outcomes ticked", async () => { + const view = await renderAudit(); + const user = userEvent.setup({ document }); + await user.click(view.getByRole("button", { name: /Outcome/ })); + await user.click( + await view.findByRole("menuitemcheckbox", { name: /Blocked/ }), + ); + await waitFor(() => expect(whats(view.container)).toEqual(["navigate"])); + expect(view.getByText("1 of 3 events")).toBeTruthy(); +}); diff --git a/app/tests/self-host-banner.test.tsx b/app/tests/self-host-banner.test.tsx index 325d5c0d5f..50b4263691 100644 --- a/app/tests/self-host-banner.test.tsx +++ b/app/tests/self-host-banner.test.tsx @@ -50,7 +50,13 @@ afterEach(() => { clients.length = 0; globalThis.fetch = originalFetch; }); -afterAll(() => GlobalRegistrator.unregister()); +// A failed save rolls the cache back after the last test has seen the banner return, and React +// renders that after `cleanup`. Unregistering at once removes `window` under it, and the scheduler's +// read of `window.event` throws between tests, failing the run with every test passed. +afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); const banner = { name: "Self-host OpenBot" } as const; diff --git a/bun.lock b/bun.lock index d982d5df56..33b3d45756 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.27", + "@tanstack/react-table": "^9.3.1", "better-auth": "^1.6.27", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", @@ -788,6 +789,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="], + "@tanstack/react-table": ["@tanstack/react-table@9.3.1", "", { "dependencies": { "@tanstack/react-store": "^0.11.2", "@tanstack/table-core": "9.3.1" }, "peerDependencies": { "react": ">=18" } }, "sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.13", "", { "dependencies": { "@tanstack/virtual-core": "3.17.11" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg=="], "@tanstack/router-core": ["@tanstack/router-core@1.171.27", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g=="], @@ -800,6 +803,8 @@ "@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="], + "@tanstack/table-core": ["@tanstack/table-core@9.3.1", "", { "dependencies": { "@tanstack/store": "^0.11.2" } }, "sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.11", "", {}, "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], @@ -2512,6 +2517,10 @@ "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-table/@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/table-core/@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + "@types/mdast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@whatwg-node/node-fetch/@fastify/busboy": ["@fastify/busboy@3.2.2", "", {}, "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg=="], @@ -2852,6 +2861,8 @@ "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/react-table/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
+ ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ); +} + +export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow }; diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index e254003b9f..989b34df4a 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -1,23 +1,30 @@ -import { IconDownload, IconRefresh, IconSearch } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconBan, + IconDeviceDesktop, + IconDownload, + IconEyeOff, + IconList, + IconRefresh, + IconSearch, +} from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; -import { - PageEmpty, - PageSection, - PageShell, -} from "@/components/layout/page-shell"; +import type { AuditEvent } from "@/components/audit/audit-cells"; +import { AuditTable } from "@/components/audit/audit-table"; +import { PageEmpty, PageShell } from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { useBotNames } from "@/lib/agents/bot-names"; import { DID_NOT_HAPPEN_EVENT_TYPES, eventTypeFilter, - outcomeOf, REFUSED_EVENT_TYPES, } from "@/lib/audit/outcome"; import { auditEventsQueryOptions, auditExportUrl } from "@/lib/audit/queries"; -import { silenceOf } from "@/lib/audit/silence"; import { client } from "@/lib/client"; +import { cn } from "@/lib/utils"; /** * Read surface for policy, computer, component, MCP, and credential audit events. @@ -26,20 +33,6 @@ export const Route = createFileRoute("/_authed/admin/audit")({ component: AuditPage, }); -/** One row as the API returns it. */ -type AuditEvent = { - id: string; - actorUserId: string | null; - /** Absent on a deployment that has not migrated yet. */ - initiatorKind?: string; - initiatorId?: string | null; - eventType: string; - targetType: string; - targetId: string | null; - payload: Record; - createdAt: string; -}; - /* * The saved views, built from the same lists the row label and colour are decided by. * @@ -49,21 +42,32 @@ type AuditEvent = { * answered. */ const FILTERS = [ - { label: "Everything", search: "" }, - { label: "Computer actions", search: "?eventType=computer.action_allowed" }, - { label: "Blocked", search: eventTypeFilter(REFUSED_EVENT_TYPES) }, + { label: "Everything", icon: IconList, search: "" }, + { + label: "Computer actions", + icon: IconDeviceDesktop, + search: "?eventType=computer.action_allowed", + }, + { + label: "Blocked", + icon: IconBan, + search: eventTypeFilter(REFUSED_EVENT_TYPES), + }, { label: "Did not happen", + icon: IconAlertTriangle, search: eventTypeFilter(DID_NOT_HAPPEN_EVENT_TYPES), }, { label: "Nobody watching", + icon: IconEyeOff, search: "?initiatorKind=routine,handoff,responsibility,memory", }, ] as const; function AuditPage() { const [search, setSearch] = useState(FILTERS[0].search); + const [query, setQuery] = useState(""); const events = useQuery(auditEventsQueryOptions(search)); const rows = (events.data?.events ?? []) as AuditEvent[]; const nameFor = useBotNames(); @@ -110,442 +114,117 @@ function AuditPage() { return ( /* - * THE ONE WIDE PAGE IN ADMIN, and the one that keeps a table. Five columns of short values is - * what a log is; rows of prose would make every entry a paragraph and the scanning this page - * exists for impossible. It takes the same header and the same type scale as everything else, - * and differs only where the content forces it to. + * THE ONE FULL-WIDTH PAGE IN ADMIN, and the one that keeps a table. The page is the window's + * height; the title, the views and the search stay where they are and only the rows scroll, + * because a log is scanned across and rows of prose would make every entry a paragraph. */ - - - - } description="Every action a Bot took, and every one this deployment's policy refused." title="Audit" - width="wide" + width="full" > - -
- {FILTERS.map((filter) => ( - - ))} -
- - {exportError ? ( -

- {exportError} -

- ) : null} - {exportCapped ? ( -

- The file has the newest {exportCapped.toLocaleString()} events, not - all of them. Narrow the filter or dates to export the rest. -

- ) : null} - {events.isPending ? null : events.isError ? ( -

- Couldn't load the audit log. Reload the page. -

- ) : rows.length === 0 ? ( - - No events match this filter yet. - - ) : ( -
- - - - - - - - - - - - - {rows.map((event) => ( - - ))} - -
WhenWhatOnBotStarted byDecision
-
- )} -
+ {events.isPending ? null : events.isError ? ( +

+ Couldn't load the audit log. Reload the page. +

+ ) : ( + ( +
+ {/* + * The saved views are one control with one answer, so they are drawn as a segmented + * group: the pressed one is filled, the rest sit flat inside the same outline. + */} +
+ View +
+ {FILTERS.map((filter) => { + const active = search === filter.search; + return ( + + ); + })} +
+
+
+
+ + setQuery(event.target.value)} + placeholder="Search events…" + type="search" + value={query} + /> +
+ {filters} + + {count} + +
+ + +
+
+ {exportError ? ( +

+ {exportError} +

+ ) : null} + {exportCapped ? ( +

+ The file has the newest {exportCapped.toLocaleString()}{" "} + events, not all of them. Narrow the filter or dates to export + the rest. +

+ ) : null} + {rows.length === 0 ? ( + + No events match this filter yet. + + ) : null} +
+ )} + /> + )}
); } - -function StartedBy({ - event, - nameFor, -}: { - event: AuditEvent; - nameFor: (botId: string) => string; -}) { - if (event.initiatorKind === "routine") { - return ( - - A routine - - ); - } - if (event.initiatorKind === "handoff") { - return ( - - {event.initiatorId - ? `Handed on by ${nameFor(event.initiatorId)}` - : "Handed on"} - - ); - } - if ( - event.initiatorKind === "responsibility" || - event.initiatorKind === "memory" - ) { - return ( - - {event.initiatorKind === "memory" - ? "A memory source" - : "A responsibility"} - - ); - } - if (event.initiatorKind === "deployment") { - return This deployment; - } - return A person; -} - -function Row({ - event, - nameFor, -}: { - event: AuditEvent; - nameFor: (botId: string) => string; -}) { - const payload = event.payload ?? {}; - const decision = (payload.decision ?? {}) as { - allowed?: boolean; - mode?: string; - rule?: string | null; - carriedOut?: boolean; - }; - const element = payload.element as - | { role?: string; name?: string } - | string - | undefined; - const outcome = outcomeOf(event.eventType); - const refused = outcome === "refused"; - const stalled = event.eventType === "agent.stream_stalled"; - /* - * Three different things, and the difference is what somebody comes to this row to find out. - * - * A person naming a coworker, the router matching one, and the router giving up and using the - * default are not the same event, and one label covering all three would make the row worth less - * than the reason line under it. Nothing here is a refusal, so none of them take the refusal - * colour. - */ - const routed = - event.eventType === "channel.routed" - ? payload.viaMention === true - ? "The person chose this Bot" - : payload.fallback === true - ? "Sent to the default Bot" - : "Sent to the Bot it was meant for" - : null; - // Allowed by policy but not carried out. A stalled turn belongs in the same family: the Bot was - // asked and the answer never arrived. Colour is how this table is read, and a row left in the - // muted foreground reads as "Allowed", which a turn nobody ever got an answer to was not. - const failed = outcome === "did-not-happen"; - const silence = stalled ? silenceOf(payload) : null; - - return ( -
- {new Date(event.createdAt).toLocaleTimeString()} - - {/* Strip the internal computer tool namespace for display. */} - {typeof payload.action === "string" - ? payload.action.replace("computer_", "") - : event.eventType} - - {/* - * A routing row's subject is the coworker it went to, and it is the only thing on the row - * worth reading. Its target type is `agent`, which is not a named target because everywhere - * else an agent id appears it belongs in the Bot column; here nothing acted, so there is no - * Bot and the target is all there is. Rendered through `nameFor` so it reads as the name on - * the roster rather than the immutable id. - */} - {event.eventType === "channel.routed" && event.targetId ? ( - {nameFor(event.targetId)} - ) : /* - * A discovery row's subject is the narrowing itself, so the numbers are the subject. A - * reader asking "why did it not call the tool" needs to see that eleven of thirty were - * offered before anything else on the row means anything. - */ - event.eventType === "mcp.tools_discovered" ? ( - - {typeof payload.offered === "number" && - typeof payload.granted === "number" - ? `${payload.offered} of ${payload.granted} tools` - : "-"} - - ) : /* Named targets and file paths are the audit subject before page elements. */ - NAMED_TARGETS.has(event.targetType) && event.targetId ? ( - - {event.targetId} - {typeof payload.function === "string" ? ( - - {" "} - · {payload.function} - - ) : null} - - ) : typeof payload.file === "string" ? ( - {payload.file} - ) : typeof payload.command === "string" ? ( - // The command is the subject of its own row, the way a path is for a file action. - {payload.command} - ) : typeof element === "object" && element?.name ? ( - - {element.name} - {element.role ? ( - ({element.role}) - ) : null} - - ) : typeof element === "string" ? ( - {element} - ) : ( - - - )} - {/* Page host is meaningful only for browser actions, not workspace file actions. */} - {typeof payload.file !== "string" && - typeof payload.command !== "string" && - typeof payload.page === "string" && - payload.page ? ( -
- {hostOf(payload.page)} -
- ) : null} -
- {typeof payload.bot === "string" ? ( - // Keep the immutable bot id available even when names collide. - {nameFor(payload.bot)} - ) : ( - "-" - )} - - - - - {routed ?? - DECISIONS[event.eventType] ?? - (refused ? "Blocked" : failed ? "Did not happen" : "Allowed")} - - {/* Refusal reasons mirror the conversation-facing reason. */} - {(event.eventType === "component.refused" || - event.eventType === "component.function_refused" || - event.eventType === "mcp.call_rejected") && - typeof payload.reason === "string" ? ( -
- {payload.reason} -
- ) : null} - {/* - * Why this run was offered what it was, which is the only part of a discovery row that - * cannot be worked out from the numbers. "Nothing declared" and "selector unavailable" both - * offer everything and mean entirely different things about the deployment. - */} - {event.eventType === "mcp.tools_discovered" && - typeof payload.reason === "string" ? ( -
- {DISCOVERY_REASONS[payload.reason] ?? payload.reason} - {Array.isArray(payload.skills) && payload.skills.length > 0 - ? `: ${payload.skills.join(", ")}` - : ""} -
- ) : null} - {event.eventType === "mcp.callback_refused" && - typeof payload.refusal === "string" ? ( -
- {payload.refusal} -
- ) : null} - {/* - * Why the conversation went where it went, which is the whole reason the row is written. - * Without it a routing row says "Allowed" and names nobody, which is indistinguishable from - * a row that failed to write. - */} - {event.eventType === "channel.routed" && - typeof payload.reason === "string" ? ( - /* - * A width rather than a max-width, because the table lays itself out from its content and - * a max-width on a block inside a cell does not constrain that. A router's reason is a - * sentence a model wrote, not a rule name, and left unbounded in the last column it - * pushes the table wider than the page and the end of the sentence goes off the edge, - * where nobody scrolls to find it. - */ -
- {payload.reason} -
- ) : null} - {event.eventType === "bot.declined" && - typeof payload.reason === "string" ? ( -
- {payload.reason} - , reported by the Bot itself -
- ) : null} - {failed && typeof payload.failure === "string" ? ( -
- {payload.failure} -
- ) : null} - {/* - * The two numbers the stall row is worth reading for. Without them every stalled turn looks - * the same, and the difference between an endpoint that dies halfway through an answer and - * one that never begins is the difference between a slow Bot and a dead one. - */} - {silence ? ( -
{silence}
- ) : null} - {/* Show concrete policy rules, but suppress the uninformative default `true` allow rule. */} - {decision.rule && decision.rule !== "true" ? ( -
- {decision.rule} -
- ) : null} - {decision.mode === "dry-run" && - decision.allowed === false && - decision.carriedOut ? ( -
- dry-run: recorded, not enforced -
- ) : null} -