feat: add page for ai-bridge interception logs (#20331)

Relates #20287 

This pull-request introduces a basic routing for `AI Governance`'s
Request Logs feature. Currently we're just pulling back the basics from
the database and rendering it into the table. Nothing exciting.

The idea is to extend further upon the `/aigovernance` route so it has
been appropriately wrapped with a `<AIGovernanceLayout />` to introduce
a navigation later.
This commit is contained in:
Jake Howell
2025-11-12 11:41:41 +11:00
committed by GitHub
parent dc21699e8c
commit 3bb7975a17
17 changed files with 718 additions and 0 deletions
+10
View File
@@ -2716,6 +2716,16 @@ class ExperimentalApiMethods {
setTimeout(() => res(), 500);
});
};
getAIBridgeInterceptions = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams(
"/api/experimental/aibridge/interceptions",
options,
);
const response =
await this.axios.get<TypesGen.AIBridgeListInterceptionsResponse>(url);
return response.data;
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
+21
View File
@@ -0,0 +1,21 @@
import { API } from "api/api";
import type { AIBridgeListInterceptionsResponse } from "api/typesGenerated";
import { useFilterParamsKey } from "components/Filter/Filter";
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
export const paginatedInterceptions = (
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<AIBridgeListInterceptionsResponse, string> => {
return {
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
queryKey: ({ payload, pageNumber }) => {
return ["aiBridgeInterceptions", payload, pageNumber] as const;
},
queryFn: ({ limit, offset, payload }) =>
API.experimental.getAIBridgeInterceptions({
offset,
limit,
q: payload,
}),
};
};
@@ -18,6 +18,7 @@ interface DeploymentDropdownProps {
canViewAuditLog: boolean;
canViewConnectionLog: boolean;
canViewHealth: boolean;
canViewAIGovernance: boolean;
}
export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
@@ -26,6 +27,7 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewAuditLog,
canViewConnectionLog,
canViewHealth,
canViewAIGovernance,
}) => {
if (
!canViewAuditLog &&
@@ -56,6 +58,7 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewAuditLog={canViewAuditLog}
canViewConnectionLog={canViewConnectionLog}
canViewHealth={canViewHealth}
canViewAIGovernance={canViewAIGovernance}
/>
</PopoverContent>
</Popover>
@@ -68,6 +71,7 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
canViewAuditLog,
canViewHealth,
canViewConnectionLog,
canViewAIGovernance,
}) => {
return (
<nav>
@@ -111,6 +115,17 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
</MenuItem>
</PopoverClose>
)}
{canViewAIGovernance && (
<PopoverClose asChild>
<MenuItem
component={NavLink}
to="/aigovernance"
css={styles.menuItem}
>
AI Governance
</MenuItem>
</PopoverClose>
)}
{canViewHealth && (
<PopoverClose asChild>
<MenuItem component={NavLink} to="/health" css={styles.menuItem}>
@@ -25,6 +25,8 @@ export const Navbar: FC = () => {
featureVisibility.audit_log && permissions.viewAnyAuditLog;
const canViewConnectionLog =
featureVisibility.connection_log && permissions.viewAnyConnectionLog;
const canViewAIGovernance =
featureVisibility.aibridge && permissions.viewAnyAIBridgeInterception;
const uniqueLinks = new Map<string, LinkConfig>();
for (const link of appearance.support_links ?? []) {
@@ -44,6 +46,7 @@ export const Navbar: FC = () => {
canViewHealth={canViewHealth}
canViewAuditLog={canViewAuditLog}
canViewConnectionLog={canViewConnectionLog}
canViewAIGovernance={canViewAIGovernance}
proxyContextValue={proxyContextValue}
/>
);
@@ -35,6 +35,7 @@ interface NavbarViewProps {
canViewAuditLog: boolean;
canViewConnectionLog: boolean;
canViewHealth: boolean;
canViewAIGovernance: boolean;
proxyContextValue?: ProxyContextValue;
}
@@ -55,6 +56,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewHealth,
canViewAuditLog,
canViewConnectionLog,
canViewAIGovernance,
proxyContextValue,
}) => {
const webPush = useWebpushNotifications();
@@ -95,6 +97,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewDeployment={canViewDeployment}
canViewHealth={canViewHealth}
canViewConnectionLog={canViewConnectionLog}
canViewAIGovernance={canViewAIGovernance}
/>
</div>
+7
View File
@@ -169,6 +169,13 @@ export const permissionChecks = {
},
action: "read",
},
viewAnyAIBridgeInterception: {
object: {
resource_type: "aibridge_interception",
any_org: true,
},
action: "read",
},
} as const satisfies Record<string, AuthorizationCheck>;
export const canViewDeploymentSettings = (
@@ -0,0 +1,32 @@
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipIconTrigger,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import type { FC } from "react";
import { docs } from "utils/docs";
export const AIGovernanceHelpTooltip: FC = () => {
return (
<HelpTooltip>
<HelpTooltipIconTrigger />
<HelpTooltipContent>
<HelpTooltipTitle>What is AI Governance?</HelpTooltipTitle>
<HelpTooltipText>
AI Governance is a proxy that unifies and audits LLM usage across your
organization.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href={docs("/ai-coder/ai-bridge")}>
What we track
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltipContent>
</HelpTooltip>
);
};
@@ -0,0 +1,30 @@
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import type { FC, PropsWithChildren } from "react";
import { Outlet } from "react-router";
import { AIGovernanceHelpTooltip } from "./AIGovernanceHelpTooltip";
const AIGovernanceLayout: FC<PropsWithChildren> = () => {
return (
<Margins className="pb-12">
<PageHeader>
<PageHeaderTitle>
<div className="flex items-center gap-2">
<span>AI Governance</span>
<AIGovernanceHelpTooltip />
</div>
</PageHeaderTitle>
<PageHeaderSubtitle>
Manage usage for your organization.
</PageHeaderSubtitle>
</PageHeader>
<Outlet />
</Margins>
);
};
export default AIGovernanceLayout;
@@ -0,0 +1,66 @@
import { paginatedInterceptions } from "api/queries/aiBridge";
import { useFilter } from "components/Filter/Filter";
import { useUserFilterMenu } from "components/Filter/UserFilter";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import type { FC } from "react";
import { useSearchParams } from "react-router";
import { pageTitle } from "utils/page";
import { useProviderFilterMenu } from "./filter/filter";
import { RequestLogsPageView } from "./RequestLogsPageView";
const RequestLogsPage: FC = () => {
const feats = useFeatureVisibility();
const isRequestLogsVisible = Boolean(feats.aibridge);
const [searchParams, setSearchParams] = useSearchParams();
const interceptionsQuery = usePaginatedQuery(
paginatedInterceptions(searchParams),
);
const filter = useFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: interceptionsQuery.goToFirstPage,
});
const userMenu = useUserFilterMenu({
value: filter.values.initiator,
onChange: (option) =>
filter.update({
...filter.values,
initiator: option?.value,
}),
});
const providerMenu = useProviderFilterMenu({
value: filter.values.provider,
onChange: (option) =>
filter.update({
...filter.values,
provider: option?.value,
}),
});
return (
<>
<title>{pageTitle("Request Logs", "AI Governance")}</title>
<RequestLogsPageView
isLoading={interceptionsQuery.isLoading}
isRequestLogsVisible={isRequestLogsVisible}
interceptions={interceptionsQuery.data?.results}
interceptionsQuery={interceptionsQuery}
filterProps={{
filter,
error: interceptionsQuery.error,
menus: {
user: userMenu,
provider: providerMenu,
},
}}
/>
</>
);
};
export default RequestLogsPage;
@@ -0,0 +1,77 @@
import { MockInterception } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
getDefaultFilterProps,
MockMenu,
} from "components/Filter/storyHelpers";
import {
mockInitialRenderResult,
mockSuccessResult,
} from "components/PaginationWidget/PaginationContainer.mocks";
import type { ComponentProps } from "react";
import { RequestLogsPageView } from "./RequestLogsPageView";
type FilterProps = ComponentProps<typeof RequestLogsPageView>["filterProps"];
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
query: "owner:me",
values: {
username: undefined,
provider: undefined,
},
menus: {
user: MockMenu,
provider: MockMenu,
},
});
const interceptions = [MockInterception, MockInterception, MockInterception];
const meta: Meta<typeof RequestLogsPageView> = {
title: "pages/AIGovernancePage/RequestLogsPageView",
component: RequestLogsPageView,
args: {},
};
export default meta;
type Story = StoryObj<typeof RequestLogsPageView>;
export const Paywall: Story = {
args: {
isRequestLogsVisible: false,
},
};
export const Loaded: Story = {
args: {
isRequestLogsVisible: true,
interceptions,
filterProps: {
...defaultFilterProps,
},
interceptionsQuery: mockSuccessResult,
},
};
export const Empty: Story = {
args: {
isRequestLogsVisible: true,
interceptions: [],
filterProps: {
...defaultFilterProps,
},
interceptionsQuery: mockSuccessResult,
},
};
export const Loading: Story = {
args: {
isLoading: true,
isRequestLogsVisible: true,
interceptions: [],
filterProps: {
...defaultFilterProps,
},
interceptionsQuery: mockInitialRenderResult,
},
};
@@ -0,0 +1,82 @@
import type { AIBridgeInterception } from "api/typesGenerated";
import {
PaginationContainer,
type PaginationResult,
} from "components/PaginationWidget/PaginationContainer";
import { Paywall } from "components/Paywall/Paywall";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import { TableEmpty } from "components/TableEmpty/TableEmpty";
import { TableLoader } from "components/TableLoader/TableLoader";
import type { ComponentProps, FC } from "react";
import { docs } from "utils/docs";
import { RequestLogsFilter } from "./filter/RequestLogsFilter";
import { RequestLogsRow } from "./RequestLogsRow/RequestLogsRow";
interface RequestLogsPageViewProps {
isLoading: boolean;
isRequestLogsVisible: boolean;
interceptions?: readonly AIBridgeInterception[];
interceptionsQuery: PaginationResult;
filterProps: ComponentProps<typeof RequestLogsFilter>;
}
export const RequestLogsPageView: FC<RequestLogsPageViewProps> = ({
isLoading,
isRequestLogsVisible,
interceptions,
interceptionsQuery,
filterProps,
}) => {
if (!isRequestLogsVisible) {
return (
<Paywall
message="AI Governance"
description="AI Governance allows you to monitor and manage AI requests. You need an Premium license to use this feature."
documentationLink={docs("/ai-coder/ai-bridge")}
/>
);
}
return (
<>
<RequestLogsFilter {...filterProps} />
<PaginationContainer
query={interceptionsQuery}
paginationUnitLabel="interceptions"
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>User</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Tokens</TableHead>
<TableHead>Tool Calls</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableLoader />
) : interceptions?.length === 0 ? (
<TableEmpty message={"No request logs available"} />
) : (
interceptions?.map((interception) => (
<RequestLogsRow
interception={interception}
key={interception.id}
/>
))
)}
</TableBody>
</Table>
</PaginationContainer>
</>
);
};
@@ -0,0 +1,27 @@
import { MockInterception } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Table, TableBody } from "components/Table/Table";
import { RequestLogsRow } from "./RequestLogsRow";
const meta: Meta<typeof RequestLogsRow> = {
title: "pages/AIGovernancePage/RequestLogsRow",
component: RequestLogsRow,
decorators: [
(Story) => (
<Table>
<TableBody>
<Story />
</TableBody>
</Table>
),
],
};
export default meta;
type Story = StoryObj<typeof RequestLogsRow>;
export const Close: Story = {
args: {
interception: MockInterception,
},
};
@@ -0,0 +1,200 @@
import type { AIBridgeInterception } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { TableCell, TableRow } from "components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import {
ArrowDownIcon,
ArrowUpIcon,
ChevronDownIcon,
ChevronRightIcon,
} from "lucide-react";
import { type FC, Fragment, useState } from "react";
import { cn } from "utils/cn";
type RequestLogsRowProps = {
interception: AIBridgeInterception;
};
export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
const [isOpen, setIsOpen] = useState(false);
const [firstPrompt] = interception.user_prompts;
const inputTokens = interception.token_usages.reduce(
(acc, tokenUsage) => acc + tokenUsage.input_tokens,
0,
);
const outputTokens = interception.token_usages.reduce(
(acc, tokenUsage) => acc + tokenUsage.output_tokens,
0,
);
const toolCalls = interception.tool_usages.length;
return (
<>
<TableRow
className="select-none cursor-pointer hover:bg-surface-secondary"
onClick={() => setIsOpen(!isOpen)}
>
<TableCell>
<div
className={cn([
"flex items-center gap-2",
isOpen && "text-content-primary",
])}
>
{isOpen ? (
<ChevronDownIcon className="size-icon-xs" />
) : (
<ChevronRightIcon className="size-icon-xs" />
)}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
{new Date(interception.started_at).toLocaleString()}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar
fallback={interception.initiator.username}
src={interception.initiator.avatar_url}
/>
<div className="font-medium">{interception.initiator.username}</div>
</div>
</TableCell>
<TableCell>{firstPrompt?.prompt}</TableCell>
<TableCell>
<div className="flex items-center gap-4">
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<ArrowDownIcon className="size-icon-xs" />
<div>{inputTokens}</div>
</div>
</TooltipTrigger>
<TooltipContent>Input Tokens</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<ArrowUpIcon className="size-icon-xs" />
<div>{outputTokens}</div>
</div>
</TooltipTrigger>
<TooltipContent>Output Tokens</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell>{toolCalls}</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
<div className="flex flex-col gap-4">
<dl
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
])}
>
<dt>Request ID:</dt>
<dd data-chromatic="ignore">{interception.id}</dd>
<dt>Start Time:</dt>
<dd data-chromatic="ignore">
{new Date(interception.started_at).toLocaleString()}
</dd>
{interception.ended_at && (
<>
<dt>End Time:</dt>
<dd data-chromatic="ignore">
{new Date(interception.ended_at).toLocaleString()}
</dd>
</>
)}
<dt>Initiator:</dt>
<dd data-chromatic="ignore">
{interception.initiator.username}
</dd>
<dt>Model:</dt>
<dd data-chromatic="ignore">{interception.model}</dd>
<dt>Input Tokens:</dt>
<dd data-chromatic="ignore">{inputTokens}</dd>
<dt>Output Tokens:</dt>
<dd data-chromatic="ignore">{outputTokens}</dd>
<dt>Tool Calls:</dt>
<dd data-chromatic="ignore">
{interception.tool_usages.length}
</dd>
</dl>
{interception.user_prompts.length > 0 && (
<div className="flex flex-col gap-2">
<div>Prompts</div>
<div
className="bg-surface-secondary rounded-md p-4"
data-chromatic="ignore"
>
{interception.user_prompts.map((prompt) => (
<Fragment key={prompt.id}>{prompt.prompt}</Fragment>
))}
</div>
</div>
)}
{interception.tool_usages.length > 0 && (
<div className="flex flex-col gap-2">
<div>Tool Usages</div>
<div
className="bg-surface-secondary rounded-md p-4"
data-chromatic="ignore"
>
{interception.tool_usages.map((toolUsage) => {
return (
<dl
key={toolUsage.id}
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"[&_dt]:text-content-primary [&_dd]:font-mono [&_dt]:leading-[22px] [&_dt]:font-medium",
])}
>
<dt>{toolUsage.tool}</dt>
<dd>
<div className="flex flex-col gap-2">
<div>{toolUsage.input}</div>
{toolUsage.invocation_error && (
<div className="text-content-destructive">
{toolUsage.invocation_error}
</div>
)}
</div>
</dd>
</dl>
);
})}
</div>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
};
@@ -0,0 +1,44 @@
import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import type { FC } from "react";
import { ProviderFilter, type ProviderFilterMenu } from "./filter";
interface RequestLogsFilterProps {
filter: ReturnType<typeof useFilter>;
error?: unknown;
menus: {
user: UserFilterMenu;
provider: ProviderFilterMenu;
};
}
export const RequestLogsFilter: FC<RequestLogsFilterProps> = ({
filter,
error,
menus,
}) => {
return (
<Filter
filter={filter}
optionsSkeleton={<MenuSkeleton />}
isLoading={menus.user.isInitializing}
presets={[
{
name: "All requests",
query: "",
},
{
name: "My requests",
query: "initiator:me",
},
]}
error={error}
options={
<>
<UserMenu menu={menus.user} />
<ProviderFilter menu={menus.provider} />
</>
}
/>
);
};
@@ -0,0 +1,57 @@
import {
type UseFilterMenuOptions,
useFilterMenu,
} from "components/Filter/menu";
import {
SelectFilter,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import type { FC } from "react";
const AIBRIDGE_PROVIDERS: SelectFilterOption[] = [
{
label: "OpenAI",
value: "openai",
},
{
label: "Anthropic",
value: "anthropic",
},
];
export const useProviderFilterMenu = ({
value,
onChange,
enabled,
}: Pick<UseFilterMenuOptions, "value" | "onChange" | "enabled">) => {
return useFilterMenu({
id: "provider",
getSelectedOption: async () =>
AIBRIDGE_PROVIDERS.find((option) => option.value === value) ?? null,
getOptions: async () => {
return AIBRIDGE_PROVIDERS;
},
value,
onChange,
enabled,
});
};
export type ProviderFilterMenu = ReturnType<typeof useProviderFilterMenu>;
interface ProviderFilterProps {
menu: ProviderFilterMenu;
}
export const ProviderFilter: FC<ProviderFilterProps> = ({ menu }) => {
return (
<SelectFilter
label={"Select provider"}
placeholder={"All providers"}
emptyText="No providers found"
options={menu.searchOptions}
onSelect={(option) => menu.selectOption(option)}
selectedOption={menu.selectedOption ?? undefined}
/>
);
};
+14
View File
@@ -334,6 +334,12 @@ const ProvisionerJobsPage = lazy(
);
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
const AIGovernanceLayout = lazy(
() => import("./pages/AIGovernancePage/AIGovernanceLayout"),
);
const AIGovernanceRequestLogsPage = lazy(
() => import("./pages/AIGovernancePage/RequestLogsPage/RequestLogsPage"),
);
const RoutesWithSuspense = () => {
return (
@@ -557,6 +563,14 @@ export const router = createBrowserRouter(
</Route>
</Route>
<Route path="/aigovernance" element={<AIGovernanceLayout />}>
<Route index element={<Navigate to="request-logs" replace />} />
<Route
path="request-logs"
element={<AIGovernanceRequestLogsPage />}
/>
</Route>
<Route path="/health" element={<HealthLayout />}>
<Route index element={<Navigate to="access-url" replace />} />
<Route path="access-url" element={<AccessURLPage />} />
+30
View File
@@ -3101,6 +3101,7 @@ export const MockPermissions: Permissions = {
editAnySettings: true,
viewAnyIdpSyncSettings: true,
viewAnyMembers: true,
viewAnyAIBridgeInterception: true,
};
export const MockNoPermissions: Permissions = {
@@ -3129,6 +3130,7 @@ export const MockNoPermissions: Permissions = {
editAnySettings: false,
viewAnyIdpSyncSettings: false,
viewAnyMembers: false,
viewAnyAIBridgeInterception: true,
};
export const MockOrganizationPermissions: OrganizationPermissions = {
@@ -5051,3 +5053,31 @@ export const MockTasks = [
},
},
] satisfies TypesGen.Task[];
export const MockInterception: TypesGen.AIBridgeInterception = {
id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
initiator: {
id: "1ebb7622-e6ea-45b4-b244-dda30afc7238",
username: "testuser",
avatar_url: "https://example.com/avatar.png",
},
provider: "openai",
model: "gpt-4o",
started_at: "2022-05-17T17:39:01.382927298Z",
ended_at: "2022-05-17T17:39:01.382927298Z",
token_usages: [
{
id: "32e7fd17-24be-46b9-b867-2f0adfd42aff",
interception_id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
provider_response_id: "res_1234567890",
input_tokens: 5,
output_tokens: 1,
metadata: {},
created_at: "2022-05-17T17:39:01.382927298Z",
},
],
metadata: {},
user_prompts: [],
tool_usages: [],
api_key_id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
};