From 3f3fd1c4d7c68768eb97384bfe2baa2392020d3e Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 30 Jul 2026 13:23:51 +0200 Subject: [PATCH] feat: show network request summary on AI session detail card (#27418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the AI session network summary. Adds Network calls, Blocked network requests, and Top domains rows to the Session summary card on the individual AI session detail page, driven by the network fields on the session threads response. Renders "Disabled" when network monitoring was not active and "No activity" when there were no calls. Covered by Storybook stories for each state. ### PR map (merge strictly bottom-up) This change is a 4-PR stack. Each PR depends on all the ones below it, so merge in this exact order: 1. #27417 โ€” backend network summary 2. #27418 โ€” frontend summary rows 3. #27425 โ€” backend per-call list `network_call_logs` 4. #27426 โ€” frontend network-calls panel Refs AIGOV-463 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Cian Johnston --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- codersdk/aibridge.go | 2 +- docs/ai-coder/ai-gateway/audit.md | 24 ++++-- docs/reference/api/schemas.md | 32 ++++---- site/src/api/typesGenerated.ts | 2 +- .../ListSessionsPage/ListSessionsPageView.tsx | 2 +- .../NetworkCallBadges.stories.tsx | 4 +- .../pages/AIBridgePage/NetworkCallBadges.tsx | 24 ++---- .../AIBridgePage/NetworkRequestStates.tsx | 17 ++++ .../SessionSummaryTable.stories.tsx | 81 ++++++++++++++++++ .../SessionSummaryTable.tsx | 82 ++++++++++++++++++- .../SessionThreadsPageView.tsx | 11 +++ 13 files changed, 241 insertions(+), 44 deletions(-) create mode 100644 site/src/pages/AIBridgePage/NetworkRequestStates.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f8fe77784a..3ce8e342ab 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15561,7 +15561,7 @@ const docTemplate = `{ } }, "network_calls": { - "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", + "description": "NetworkCalls summarizes the Agent Firewall network requests made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ { "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3ec261450c..92eecc3601 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13855,7 +13855,7 @@ } }, "network_calls": { - "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", + "description": "NetworkCalls summarizes the Agent Firewall network requests made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ { "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 31d6ae52ca..df40066524 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -141,7 +141,7 @@ type AIBridgeSession struct { EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"` Threads int64 `json:"threads"` TokenUsageSummary AIBridgeSessionTokenUsageSummary `json:"token_usage_summary"` - // NetworkCalls summarizes the Agent Firewall network calls made during the + // NetworkCalls summarizes the Agent Firewall network requests made during the // session. A nil value means the session did not pass through Agent // Firewall, so network call monitoring was not active, which the UI // surfaces as "Disabled". diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index 6cc8ef955f..b43f898441 100644 --- a/docs/ai-coder/ai-gateway/audit.md +++ b/docs/ai-coder/ai-gateway/audit.md @@ -48,12 +48,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, network calls, thread count, and timestamp. +client, token usage, network requests, 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. +The **Network Requests** column reports the total and blocked +[Agent Firewall](../agent-firewall/index.md) requests for the session. It shows +`No activity` when the session made no requests, and `Disabled` when the session +did not pass through Agent Firewall, so no network requests were monitored. Select one to view its full details. @@ -66,6 +66,20 @@ Click into a session to see a chronological causal chain of events. Within a thread, each step shows token usage, tool call details (including arguments and MCP server URLs), duration, and any errors or warnings. +The **Session summary** card beside the timeline reports the session's +[Agent Firewall](../agent-firewall/index.md) activity: + +- **Network requests** is the total number of requests the session made. It + shows `Disabled` when the session did not pass through Agent Firewall, so + monitoring was not active, and `No activity` when the session made no + requests. +- **Blocked network requests** is the subset of those requests that the + allow-list denied. This row appears only when the session made at least one + request, and is highlighted when any were blocked. +- **Top domains** names the destination host the session contacted most, and + appears whenever at least one domain was recorded. When the session contacted + more than one distinct domain, a `+N more` count follows. + ![Session detail](../../images/aibridge/session_detail.png) ## Conducting a forensic audit diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6f29b681fd..e1d7560090 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -622,22 +622,22 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `last_active_at` | string | false | | | -| `last_prompt` | string | false | | | -| `metadata` | object | false | | | -| ยป `[any property]` | any | false | | | -| `models` | array of string | false | | | -| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | integer | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `last_active_at` | string | false | | | +| `last_prompt` | string | false | | | +| `metadata` | object | false | | | +| ยป `[any property]` | any | false | | | +| `models` | array of string | false | | | +| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network requests made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | integer | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | ## codersdk.AIBridgeSessionNetworkCallSummary diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e7ae346391..d6f7e516f4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -141,7 +141,7 @@ export interface AIBridgeSession { readonly threads: number; readonly token_usage_summary: AIBridgeSessionTokenUsageSummary; /** - * NetworkCalls summarizes the Agent Firewall network calls made during the + * NetworkCalls summarizes the Agent Firewall network requests made during the * session. A nil value means the session did not pass through Agent * Firewall, so network call monitoring was not active, which the UI * surfaces as "Disabled". diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index bdc65d47e8..21299d54c3 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -86,7 +86,7 @@ export const ListSessionsPageView: FC = ({ Provider Client In/Out Tokens - Network Calls + Network Requests Threads diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx index 62d9af8f4a..f391d02042 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx @@ -49,7 +49,7 @@ export const TotalAndBlockedKeyboard: Story = { await userEvent.tab(); await waitFor(() => { const tooltip = screen.getByRole("tooltip"); - expect(tooltip).toHaveTextContent("Total calls"); + expect(tooltip).toHaveTextContent("Total requests"); expect(tooltip).toHaveTextContent("Blocked"); }); }, @@ -66,7 +66,7 @@ export const DisabledKeyboard: Story = { await userEvent.keyboard("{Enter}"); await waitFor(() => expect(screen.getByRole("dialog")).toHaveTextContent( - "Network call monitoring was not active for this session.", + "Network request monitoring was not active for this session.", ), ); }, diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx index a4e621a94d..2da53ba97c 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx @@ -2,36 +2,30 @@ 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"; +import { + NetworkMonitoringDisabled, + NetworkNoActivity, +} from "./NetworkRequestStates"; interface NetworkCallBadgesProps { - // summary is undefined when network call monitoring was not active for the - // session, which renders as "Disabled". + // summary is undefined when network request monitoring was not active for + // the session, which renders as "Disabled". summary: AIBridgeSessionNetworkCallSummary | undefined; } export const NetworkCallBadges: FC = ({ summary }) => { if (!summary) { - return ( - - Disabled - - - ); + return ; } if (summary.total === 0) { - return ( - - No activity - - ); + return ; } return ( @@ -63,7 +57,7 @@ export const NetworkCallBadges: FC = ({ summary }) => { >
- Total calls + Total requests {summary.total.toLocaleString("en-US")}
diff --git a/site/src/pages/AIBridgePage/NetworkRequestStates.tsx b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx new file mode 100644 index 0000000000..2e436003e8 --- /dev/null +++ b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx @@ -0,0 +1,17 @@ +import type { FC } from "react"; +import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; + +// Shared by the sessions list badges and the session detail summary card, which +// render the same two non-numeric states for a session's network requests but +// differ in how they present a live count. + +export const NetworkMonitoringDisabled: FC = () => ( + + Disabled + + +); + +export const NetworkNoActivity: FC = () => ( + No activity +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx index 10bc86d21b..b3e851ee4d 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor } from "storybook/test"; import { MockSession } from "#/testHelpers/entities"; import { SessionSummaryTable } from "./SessionSummaryTable"; @@ -56,3 +57,83 @@ export const LargeTokenCounts: Story = { outputTokens: 32_000, }, }; + +// Session did not pass through Agent Firewall: monitoring was not active. +export const NetworkDisabled: Story = { + args: { + ...Default.args, + networkCalls: undefined, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Disabled")).toBeInTheDocument(); + await expect(canvas.queryByText("Blocked network requests")).toBeNull(); + await expect(canvas.queryByText("Top domains")).toBeNull(); + }, +}; + +// Tabbing to the disabled indicator's info button and pressing Enter reveals +// the reason without a mouse. +export const NetworkDisabledKeyboard: Story = { + args: { + ...Default.args, + networkCalls: undefined, + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => + expect(screen.getByRole("dialog")).toHaveTextContent( + "Network request monitoring was not active for this session.", + ), + ); + }, +}; + +// Firewall active but no egress recorded. +export const NetworkNoActivity: Story = { + args: { + ...Default.args, + networkCalls: { total: 0, blocked: 0 }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("No activity")).toBeInTheDocument(); + await expect(canvas.queryByText("Blocked network requests")).toBeNull(); + }, +}; + +// Egress recorded, some blocked, across several domains. +export const NetworkActivity: Story = { + args: { + ...Default.args, + networkCalls: { total: 7, blocked: 2 }, + networkDomains: { + topDomain: { domain: "api.github.com", count: 4 }, + totalCount: 14, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Network requests")).toBeInTheDocument(); + await expect(canvas.getByText("7")).toBeInTheDocument(); + await expect( + canvas.getByText("Blocked network requests"), + ).toBeInTheDocument(); + await expect(canvas.getByText("api.github.com")).toBeInTheDocument(); + await expect(canvas.getByText("+13 more")).toBeInTheDocument(); + }, +}; + +// A single domain contacted: no "+N more" overflow. +export const NetworkSingleDomain: Story = { + args: { + ...Default.args, + networkCalls: { total: 3, blocked: 0 }, + networkDomains: { + topDomain: { domain: "api.github.com", count: 3 }, + totalCount: 1, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("api.github.com")).toBeInTheDocument(); + await expect(canvas.queryByText(/more$/)).toBeNull(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx index 7d6feaf93d..389afad00c 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx @@ -1,9 +1,19 @@ -import type { MinimalUser } from "#/api/typesGenerated"; +import { BanIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import type { + AIBridgeSessionNetworkCallSummary, + AIBridgeSessionNetworkDomain, + MinimalUser, +} from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { Badge } from "#/components/Badge/Badge"; import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon"; import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon"; import { formatDateTime } from "#/utils/time"; +import { + NetworkMonitoringDisabled, + NetworkNoActivity, +} from "../NetworkRequestStates"; import { TokenBadges } from "../TokenBadges"; import { getProviderDisplayName } from "../utils"; @@ -21,6 +31,16 @@ interface SessionSummaryTableProps { threadCount: number; toolCallCount: number; tokenUsageMetadata?: Record; + // networkCalls is undefined when the session did not pass through Agent + // Firewall, which renders as "Disabled". + networkCalls?: AIBridgeSessionNetworkCallSummary; + // networkDomains is undefined when the session contacted no destination + // hosts. totalCount is the number of distinct domains contacted, which + // renders as a "+N more" overflow beyond topDomain. + networkDomains?: { + readonly topDomain: AIBridgeSessionNetworkDomain; + readonly totalCount: number; + }; } export const SessionSummaryTable = ({ @@ -35,12 +55,25 @@ export const SessionSummaryTable = ({ threadCount, toolCallCount, tokenUsageMetadata, + networkCalls, + networkDomains, }: SessionSummaryTableProps) => { const durationInMs = endTime !== undefined ? new Date(endTime).getTime() - new Date(startTime).getTime() : undefined; + let networkCallsValue: ReactNode; + if (networkCalls === undefined) { + networkCallsValue = ; + } else if (networkCalls.total === 0) { + networkCallsValue = ; + } else { + networkCallsValue = ( + {networkCalls.total.toLocaleString("en-US")} + ); + } + return (
@@ -163,6 +196,53 @@ export const SessionSummaryTable = ({ {toolCallCount}
+ + + +
+
+ Network requests +
+
+ {networkCallsValue} +
+
+ + {networkCalls !== undefined && networkCalls.total > 0 && ( +
+
+ Blocked network requests +
+
+ {networkCalls.blocked > 0 ? ( + + + {networkCalls.blocked.toLocaleString("en-US")} + + ) : ( + {networkCalls.blocked.toLocaleString("en-US")} + )} +
+
+ )} + + {networkDomains !== undefined && ( +
+
+ Top domains +
+
+
+ {networkDomains.topDomain.domain} +
+ {networkDomains.totalCount > 1 && ( +
+ +{(networkDomains.totalCount - 1).toLocaleString("en-US")} more +
+ )} +
+
+ )}
); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index 4157c12d88..f2dee3a81e 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -75,6 +75,10 @@ export const SessionThreadsPageView: FC = ({ 0, ); + // The API returns only the single most contacted host, alongside the total + // distinct domain count that drives the "+N more" overflow. + const topDomain = session?.network_top_domains?.[0]; + return ( <>