From 0e288e876c4cdf5f524c66764589c7c0e9180895 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Tue, 27 Oct 2026 11:08:40 -0300
Subject: [PATCH 1/7] Show the audit log as a full-width table under a centred
header
---
CHANGELOG.md | 8 +
app/package.json | 1 +
app/src/components/audit/audit-cells.tsx | 438 ++++++++++++++++++
app/src/components/audit/audit-table.tsx | 246 +++++++++++
app/src/components/layout/page-shell.tsx | 22 +-
app/src/components/ui/table.tsx | 82 ++++
app/src/routes/_authed/admin/audit.tsx | 540 ++++-------------------
app/tests/audit-table.test.tsx | 120 +++++
bun.lock | 11 +
9 files changed, 1006 insertions(+), 462 deletions(-)
create mode 100644 app/src/components/audit/audit-cells.tsx
create mode 100644 app/src/components/audit/audit-table.tsx
create mode 100644 app/src/components/ui/table.tsx
create mode 100644 app/tests/audit-table.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 500cb41634..6a0e36553e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,14 @@ 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 keeps its title in the same column as every other screen and runs the table across
+the whole pane under it. 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 narrows the events already loaded by anything a
+row shows, and says how many of them match.
+
### 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..5af1dcef8d
--- /dev/null
+++ b/app/src/components/audit/audit-cells.tsx
@@ -0,0 +1,438 @@
+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, nameFor: NameFor): string {
+ const payload = event.payload ?? {};
+ const element = payload.element as Element;
+ if (event.eventType === "channel.routed" && event.targetId)
+ return nameFor(event.targetId);
+ 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)
+ : "";
+}
+
+export function botText(event: AuditEvent, nameFor: NameFor): string {
+ return typeof event.payload?.bot === "string"
+ ? nameFor(event.payload.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, nameFor),
+ hostText(event),
+ botText(event, nameFor),
+ startedByText(event, nameFor),
+ decisionText(event),
+ ...decisionDetails(event),
+ ]
+ .join(" ")
+ .toLowerCase();
+}
+
+export function OnCell({
+ event,
+ nameFor,
+}: {
+ event: AuditEvent;
+ nameFor: NameFor;
+}) {
+ const payload = event.payload ?? {};
+ const element = payload.element as Element;
+ const subject = subjectText(event, nameFor);
+ 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. A routing row's subject is the coworker it went to, named as on the
+ * roster with the immutable id kept in the tooltip.
+ */
+ const mono =
+ event.eventType === "mcp.tools_discovered" ||
+ (NAMED_TARGETS.has(event.targetType) && !!event.targetId) ||
+ typeof payload.file === "string" ||
+ typeof payload.command === "string";
+ return (
+ <>
+ {!subject ? (
+ -
+ ) : event.eventType === "channel.routed" ? (
+ {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 = event.payload?.bot;
+ // Keep the immutable bot id available even when names collide.
+ return typeof bot === "string" ? (
+
+ {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..019c897cc9
--- /dev/null
+++ b/app/src/components/audit/audit-table.tsx
@@ -0,0 +1,246 @@
+import { IconArrowDown, IconArrowUp, IconSearch } from "@tabler/icons-react";
+import {
+ type ColumnDef,
+ 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 {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { outcomeOf } from "@/lib/audit/outcome";
+import { cn } from "@/lib/utils";
+import {
+ type AuditEvent,
+ BotCell,
+ DecisionCell,
+ OnCell,
+ OUTCOME_MARK,
+ OUTCOME_ROW,
+ StartedByCell,
+ searchTextOf,
+ 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 }];
+
+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"). */
+ toolbar: (count: string) => React.ReactNode;
+}) {
+ const [sorting, setSorting] = useState(INITIAL_SORT);
+ const columns = useMemo(() => auditColumns(nameFor), [nameFor]);
+ const table = useTable({
+ features,
+ columns,
+ data: events,
+ getRowId: (event) => event.id,
+ state: { sorting, globalFilter: query },
+ onSortingChange: setSorting,
+ 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(countLabel(rows.length, events.length))}
+
+ ) : null}
>
);
diff --git a/app/src/components/ui/table.tsx b/app/src/components/ui/table.tsx
new file mode 100644
index 0000000000..d2b5894285
--- /dev/null
+++ b/app/src/components/ui/table.tsx
@@ -0,0 +1,82 @@
+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 and no scroller of its own: the page decides where the table sits, and a table
+ * wider than the pane scrolls sideways in the one container that wraps it.
+ */
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ 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..9192470fad 100644
--- a/app/src/routes/_authed/admin/audit.tsx
+++ b/app/src/routes/_authed/admin/audit.tsx
@@ -2,21 +2,18 @@ import { IconDownload, 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";
/**
@@ -26,20 +23,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.
*
@@ -64,6 +47,7 @@ const FILTERS = [
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 +94,96 @@ 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 title and description
+ * sit in the same centred column as every other screen; the log under them takes the whole
+ * pane, 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.
-
+ 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 (
-
- {/*
- * 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)}
- ) : (
- "-"
- )}
-
- ) : 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" ? (
-
- ) : 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.
- */
-
- ) : 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" ? (
-
- );
-}
-
-/**
- * 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/tests/audit-table.test.tsx b/app/tests/audit-table.test.tsx
new file mode 100644
index 0000000000..b1dc06202d
--- /dev/null
+++ b/app/tests/audit-table.test.tsx
@@ -0,0 +1,120 @@
+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 { 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: the title in the same centred column as every other screen, the
+ * table across the whole pane under it, searchable and sortable by time, each row coloured by what
+ * happened.
+ */
+beforeAll(() => GlobalRegistrator.register());
+afterEach(() => cleanup());
+afterAll(() => 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 title sits in the centred column and the table spans the pane under it", async () => {
+ const view = await renderAudit();
+ const heading = view.getByRole("heading", { name: "Audit" });
+ const table = view.container.querySelector("table") as HTMLElement;
+ const column = heading.closest(".max-w-3xl");
+ expect(column).not.toBeNull();
+ expect(column?.contains(table)).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"]);
+});
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=="],
From 3cfd82e627164682f03a07f68a4a8f7ae7da261d Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Tue, 27 Oct 2026 11:21:03 -0300
Subject: [PATCH 2/7] Fit the audit page to the window and scroll only the
table
---
app/src/components/audit/audit-table.tsx | 9 ++-
app/src/components/layout/page-shell.tsx | 88 +++++++++++++++---------
app/src/components/ui/table.tsx | 21 ++++--
app/src/routes/_authed/admin/audit.tsx | 8 +--
app/tests/audit-table.test.tsx | 16 +++--
5 files changed, 93 insertions(+), 49 deletions(-)
diff --git a/app/src/components/audit/audit-table.tsx b/app/src/components/audit/audit-table.tsx
index 019c897cc9..52ffcc1876 100644
--- a/app/src/components/audit/audit-table.tsx
+++ b/app/src/components/audit/audit-table.tsx
@@ -65,7 +65,10 @@ export function AuditTable({
events: AuditEvent[];
nameFor: NameFor;
query: string;
- /** Drawn above the table, given how many rows it shows ("3 events", "1 of 3 events"). */
+ /**
+ * Drawn above the table, given how many rows it shows ("3 events", "1 of 3 events"). It stays put;
+ * only the rows under it scroll.
+ */
toolbar: (count: string) => React.ReactNode;
}) {
const [sorting, setSorting] = useState(INITIAL_SORT);
@@ -89,7 +92,9 @@ export function AuditTable({
return (
<>
{toolbar(countLabel(rows.length, events.length))}
-
+
0 ? "min-h-0 flex-1" : undefined}
+ >
{table.getHeaderGroups().map((group) => (
diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx
index da57062e28..7fbca5c2f3 100644
--- a/app/src/components/layout/page-shell.tsx
+++ b/app/src/components/layout/page-shell.tsx
@@ -36,9 +36,10 @@ import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle";
* which is where reading stays comfortable, so the column widening does not stretch them.
*
* `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 header stays in the prose column, where every other screen's title is,
- * and only the content under it takes the whole pane, as Linear lays out its lists. It is not a
- * licence for anything else to be wide.
+ * 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" | "full";
@@ -81,22 +82,56 @@ export function PageShell({
const showToggle = useSidebarToggleVisible();
const bar = showToggle || !!backButton;
+ const header = (
+
+
+ ) : 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.
+ */
+
- ) : 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.
@@ -133,25 +168,12 @@ export function PageShell({
// Without a bar above it the heading keeps the full original space.
bar ? "pt-8" : "pt-12",
"max-w-3xl",
- width === "full" ? "pb-0" : className,
+ className,
)}
>
-
-
- ) : null}
>
);
diff --git a/app/src/components/ui/table.tsx b/app/src/components/ui/table.tsx
index d2b5894285..dfb6ea52d7 100644
--- a/app/src/components/ui/table.tsx
+++ b/app/src/components/ui/table.tsx
@@ -5,14 +5,18 @@ import { cn } from "@/lib/utils";
/**
* A data table's parts, for a log or report read across the full pane.
*
- * No card around it and no scroller of its own: the page decides where the table sits, and a table
- * wider than the pane scrolls sideways in the one container that wraps it.
+ * 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, ...props }: React.ComponentProps<"table">) {
+function Table({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps<"table"> & { containerClassName?: string }) {
return (
) {
return (
);
diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx
index 9192470fad..fb3986ccd0 100644
--- a/app/src/routes/_authed/admin/audit.tsx
+++ b/app/src/routes/_authed/admin/audit.tsx
@@ -94,9 +94,9 @@ function AuditPage() {
return (
/*
- * THE ONE FULL-WIDTH PAGE IN ADMIN, and the one that keeps a table. The title and description
- * sit in the same centred column as every other screen; the log under them takes the whole
- * pane, because a log is scanned across and rows of prose would make every entry a paragraph.
+ * 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.
*/
(
-