diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index 9842af5468..9be227b659 100644 --- a/docs/ai-coder/ai-gateway/audit.md +++ b/docs/ai-coder/ai-gateway/audit.md @@ -49,7 +49,12 @@ not just what was called. The sessions page (`http:///ai-gateway/sessions`) lists all sessions in reverse-chronological order. Each row shows the last prompt, initiator, provider, -client, token usage, thread count, and timestamp. +client, token usage, network calls, thread count, and timestamp. + +The network calls column reports the total and blocked +[Agent Firewall](../agent-firewall/index.md) calls for the session. It shows +`No activity` when the session made no calls, and `Disabled` when the session +did not pass through Agent Firewall, so no network calls were monitored. Select one to view its full details. diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx index ede9692b8e..ff32f8230c 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -106,6 +106,13 @@ export const MultipleSessions: Story = { cache_read_input_tokens: 800 * (i + 1), cache_write_input_tokens: 50 * (i + 1), }, + network_calls: [ + { total: 23, blocked: 2 }, + { total: 5, blocked: 1 }, + { total: 0, blocked: 0 }, + undefined, + { total: 150, blocked: 0 }, + ][i], })), }, }; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index ed85496fb5..bdc65d47e8 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -86,6 +86,7 @@ export const ListSessionsPageView: FC = ({ Provider Client In/Out Tokens + Network Calls Threads diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx index 18db56d938..7528e7ebb1 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx @@ -107,3 +107,27 @@ export const LargeTokenCounts: Story = { }, }, }; + +export const NetworkCallsBlocked: Story = { + args: { + session: { + ...MockSession, + network_calls: { total: 23, blocked: 2 }, + }, + }, +}; + +export const NoNetworkActivity: Story = { + args: { + session: { + ...MockSession, + network_calls: { total: 0, blocked: 0 }, + }, + }, +}; + +export const NetworkDisabled: Story = { + args: { + session: { ...MockSession, network_calls: undefined }, + }, +}; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx index a655964575..82e2cf607b 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx @@ -13,6 +13,7 @@ import { import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon"; import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon"; import { DATE_FORMAT, formatDateTime } from "#/utils/time"; +import { NetworkCallBadges } from "../NetworkCallBadges"; import { TokenBadges } from "../TokenBadges"; import { getProviderDisplayName } from "../utils"; @@ -105,6 +106,9 @@ export const ListSessionsRow: FC = ({ /> + + + {session.threads} diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx new file mode 100644 index 0000000000..62d9af8f4a --- /dev/null +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor } from "storybook/test"; +import { NetworkCallBadges } from "./NetworkCallBadges"; + +const meta: Meta = { + title: "pages/AIBridgePage/NetworkCallBadges", + component: NetworkCallBadges, +}; + +export default meta; +type Story = StoryObj; + +export const TotalAndBlocked: Story = { + args: { + summary: { total: 23, blocked: 2 }, + }, +}; + +export const NoBlocked: Story = { + args: { + summary: { total: 23, blocked: 0 }, + }, +}; + +export const NoActivity: Story = { + args: { + summary: { total: 0, blocked: 0 }, + }, +}; + +export const Disabled: Story = { + args: { + summary: undefined, + }, +}; + +export const LargeCounts: Story = { + args: { + summary: { total: 12_480, blocked: 320 }, + }, +}; + +// Tabbing to the badges reveals the breakdown tooltip without a mouse. +export const TotalAndBlockedKeyboard: Story = { + args: { + summary: { total: 23, blocked: 2 }, + }, + play: async () => { + await userEvent.tab(); + await waitFor(() => { + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("Total calls"); + expect(tooltip).toHaveTextContent("Blocked"); + }); + }, +}; + +// Tabbing to the disabled indicator's info button and pressing Enter reveals +// the reason popover without a mouse. +export const DisabledKeyboard: Story = { + args: { + summary: undefined, + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => + expect(screen.getByRole("dialog")).toHaveTextContent( + "Network call monitoring was not active for this session.", + ), + ); + }, +}; diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx new file mode 100644 index 0000000000..a4e621a94d --- /dev/null +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx @@ -0,0 +1,78 @@ +import { BanIcon } from "lucide-react"; +import type { FC } from "react"; +import type { AIBridgeSessionNetworkCallSummary } from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; +import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; + +interface NetworkCallBadgesProps { + // summary is undefined when network call monitoring was not active for the + // session, which renders as "Disabled". + summary: AIBridgeSessionNetworkCallSummary | undefined; +} + +export const NetworkCallBadges: FC = ({ summary }) => { + if (!summary) { + return ( + + Disabled + + + ); + } + + if (summary.total === 0) { + return ( + + No activity + + ); + } + + return ( + + + + + + +
+
+ Total calls + {summary.total.toLocaleString("en-US")} +
+
+ Blocked + {summary.blocked.toLocaleString("en-US")} +
+
+
+
+
+ ); +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 92f6ac4027..cf6674b2ed 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5523,6 +5523,10 @@ export const MockSession: TypesGen.AIBridgeSession = { cache_read_input_tokens: 980, cache_write_input_tokens: 120, }, + network_calls: { + total: 23, + blocked: 2, + }, last_prompt: "But *can* I really fix it?", last_active_at: "2026-03-09T10:28:15.03152Z", };