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 ( <>