diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index f2dee3a81ec..ba12f4eca13 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -134,6 +134,8 @@ export const SessionThreadsPageView: FC = ({ = { + title: "pages/AIBridgePage/NetworkCallsTable", + component: NetworkCallsTable, + args: { + summary: { total: 4, blocked: 2 }, + calls: MockAIBridgeSessionNetworkCalls, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + await canvas.findByText("Network calls (4)"); + await expect(canvas.getAllByText("Allowed")).toHaveLength(2); + await expect(canvas.getAllByText("Blocked")).toHaveLength(2); + await expect( + canvas.getByText("https://registry.npmjs.org/lodash"), + ).toBeInTheDocument(); + }, +}; + +// The header badge counts every blocked call in the session, so it can exceed +// the number of blocked rows on screen once the list is capped. +export const BlockedBadge: Story = { + args: { + summary: { total: 4, blocked: 9 }, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("Blocked network calls: 9"), + ).toBeInTheDocument(); + await expect(canvas.getAllByText("Blocked")).toHaveLength(2); + }, +}; + +export const NoBlockedCalls: Story = { + args: { + summary: { total: 1, blocked: 0 }, + calls: [MockAIBridgeSessionNetworkCalls[0]], + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (1)"); + await expect(canvas.queryByText("Blocked")).not.toBeInTheDocument(); + }, +}; + +export const ExpandRow: Story = { + play: async ({ canvas }) => { + await expect(canvas.queryByText("Protocol")).not.toBeInTheDocument(); + + await userEvent.click( + canvas.getByRole("button", { + name: /https:\/\/api\.github\.com\/repos\/coder\/coder/, + }), + ); + + await expect(canvas.getByText("Protocol")).toBeInTheDocument(); + await expect(canvas.getByText("Matched rule")).toBeInTheDocument(); + }, +}; + +export const CollapsePanel: Story = { + play: async ({ canvas }) => { + await expect( + canvas.getByText("https://api.github.com/repos/coder/coder"), + ).toBeVisible(); + + await userEvent.click(canvas.getByText("Network calls (4)")); + + await expect( + canvas.queryByText("https://api.github.com/repos/coder/coder"), + ).not.toBeInTheDocument(); + }, +}; + +export const Empty: Story = { + args: { + summary: { total: 0, blocked: 0 }, + calls: [], + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (0)"); + await expect( + canvas.getByText("No network calls were recorded for this session."), + ).toBeInTheDocument(); + }, +}; + +// A summary total with no rows to show is not a state the server produces. The +// panel still shows a single message rather than claiming both that nothing was +// recorded and that the list was capped. +export const EmptyListWithSummaryTotal: Story = { + args: { + summary: { total: 4, blocked: 2 }, + calls: [], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("No network calls were recorded for this session."), + ).toBeInTheDocument(); + await expect( + canvas.queryByText(/Showing the first/), + ).not.toBeInTheDocument(); + }, +}; + +// When the session has more calls than the server returns, the panel notes +// how many are shown. +export const Truncated: Story = { + args: { + summary: { total: 150, blocked: 2 }, + calls: MockAIBridgeSessionNetworkCalls, + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (150)"); + await expect( + canvas.getByText(/Showing the first 4 of 150 network calls\./), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx new file mode 100644 index 00000000000..d6a9cdc38b0 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx @@ -0,0 +1,189 @@ +import { BanIcon, CheckIcon, ChevronRightIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import type { + AgentFirewallLog, + AIBridgeSessionNetworkCallSummary, +} from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { CopyButton } from "#/components/CopyButton/CopyButton"; +import { formatDateTime } from "#/utils/time"; + +interface NetworkCallsTableProps { + /** + * Drives the header count and blocked badge. Reflects the whole session, so + * its total can exceed the number of rows in `calls`, which is capped + * server-side. + */ + summary: AIBridgeSessionNetworkCallSummary; + calls: readonly AgentFirewallLog[]; +} + +export const NetworkCallsTable: FC = ({ + summary, + calls, +}) => ( + +
+ + + + {summary.blocked > 0 && ( + + + Blocked network calls: + {summary.blocked.toLocaleString("en-US")} + + )} +
+ + + + +
+); + +const NetworkCallsList: FC = ({ summary, calls }) => { + if (calls.length === 0) { + return ( +

+ No network calls were recorded for this session. +

+ ); + } + + const hiddenCount = summary.total - calls.length; + + return ( + <> +
    + {calls.map((call) => ( + + ))} +
+ {hiddenCount > 0 && ( +

+ Showing the first {calls.length.toLocaleString("en-US")} of{" "} + {summary.total.toLocaleString("en-US")} network calls. +

+ )} + + ); +}; + +interface NetworkCallRowProps { + call: AgentFirewallLog; +} + +const NetworkCallRow: FC = ({ call }) => { + const timestamp = formatDateTime(new Date(call.created_at)); + + return ( +
  • + + + + + + +
    + + + {call.detail || "N/A"} + + {call.detail && ( + + )} + + + + {call.proto || "N/A"} + + + + + {call.matched_rule ?? "None"} + + + + + {timestamp} + + +
    +
    +
    +
  • + ); +}; + +const NetworkCallStatusBadge: FC<{ allowed: boolean }> = ({ allowed }) => + allowed ? ( + + + Allowed + + ) : ( + + + Blocked + + ); + +interface NetworkCallDetailRowProps { + label: string; + children: ReactNode; +} + +const NetworkCallDetailRow: FC = ({ + label, + children, +}) => ( +
    +
    {label}
    +
    {children}
    +
    +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx index 7beb04b4c67..8b6a6836a10 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; import type { AIBridgeThread } from "#/api/typesGenerated"; -import { MockSession } from "#/testHelpers/entities"; +import { + MockAIBridgeSessionNetworkCalls, + MockSession, +} from "#/testHelpers/entities"; import { SessionTimeline } from "./SessionTimeline"; // A thread with one thinking block and one tool call. @@ -124,6 +128,7 @@ const meta: Meta = { args: { initiator: MockSession.initiator, threads: [mockThread], + networkCalls: [], hasNextPage: false, isFetchingNextPage: false, onFetchNextPage: noop, @@ -135,6 +140,22 @@ type Story = StoryObj; export const OneThread: Story = {}; +// A summary is present only for sessions that passed through Agent Firewall. +// The panel sits above the threads because its counts are session-scoped +// rather than tied to any one thread. +export const WithNetworkCalls: Story = { + args: { + networkCallSummary: { total: 4, blocked: 2 }, + networkCalls: MockAIBridgeSessionNetworkCalls, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Network calls (4)")).toBeInTheDocument(); + await expect( + canvas.getByText("https://api.github.com/repos/coder/coder"), + ).toBeInTheDocument(); + }, +}; + export const MultipleThreads: Story = { args: { threads: [mockThread, mockThreadLong] }, }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index 7154e1ca7a5..b1214fc84e4 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -1,7 +1,9 @@ import { ChevronRightIcon, InfoIcon, LoaderIcon } from "lucide-react"; import { type FC, useEffect, useRef, useState } from "react"; import type { + AgentFirewallLog, AIBridgeAgenticAction, + AIBridgeSessionNetworkCallSummary, AIBridgeThread, MinimalUser, } from "#/api/typesGenerated"; @@ -21,6 +23,7 @@ import { cn } from "#/utils/cn"; import { docs } from "#/utils/docs"; import { JsonPrettyPrinter } from "../../JsonPrettyPrinter"; import { AgenticLoopTable } from "./AgenticLoopTable"; +import { NetworkCallsTable } from "./NetworkCallsTable"; import { PromptTable } from "./PromptTable"; import { ToolCallTable } from "./ToolCallTable"; @@ -408,6 +411,12 @@ const ThreadItem: FC = ({ thread, initiator }) => { interface SessionTimelineProps { initiator: MinimalUser; threads: readonly AIBridgeThread[]; + /** + * Undefined when the session did not pass through Agent Firewall, in which + * case the network calls panel is not rendered. + */ + networkCallSummary?: AIBridgeSessionNetworkCallSummary; + networkCalls: readonly AgentFirewallLog[]; hasNextPage: boolean; isFetchingNextPage: boolean; onFetchNextPage: () => void; @@ -416,6 +425,8 @@ interface SessionTimelineProps { export const SessionTimeline: FC = ({ initiator, threads, + networkCallSummary, + networkCalls, hasNextPage, isFetchingNextPage, onFetchNextPage, @@ -524,6 +535,14 @@ export const SessionTimeline: FC = ({ {/* left vertical line */}
    + {networkCallSummary && ( +
    + +
    + )} {/* threads */}
    {threads.map((thread) => ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bc3ef896c5d..7c9c16edb59 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5556,6 +5556,54 @@ export const MockSession: TypesGen.AIBridgeSession = { last_active_at: "2026-03-09T10:28:15.03152Z", }; +export const MockAIBridgeSessionNetworkCalls: readonly TypesGen.AgentFirewallLog[] = + [ + { + id: "netcall-1", + session_id: "firewall-session-1", + sequence_number: 1, + proto: "http", + method: "POST", + detail: "https://api.github.com/repos/coder/coder", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:16.000Z", + }, + { + id: "netcall-2", + session_id: "firewall-session-1", + sequence_number: 2, + proto: "http", + method: "GET", + detail: "https://registry.npmjs.org/lodash", + allowed: false, + matched_rule: null, + created_at: "2026-03-09T09:28:17.000Z", + }, + { + id: "netcall-3", + session_id: "firewall-session-1", + sequence_number: 3, + proto: "http", + method: "POST", + detail: "https://hooks.slack.com/services/T01", + allowed: false, + matched_rule: null, + created_at: "2026-03-09T09:28:18.000Z", + }, + { + id: "netcall-4", + session_id: "firewall-session-1", + sequence_number: 4, + proto: "dns", + method: "A", + detail: "api.github.com", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:19.000Z", + }, + ]; + export const MockAIProviderOpenAI: TypesGen.AIProvider = { id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", type: "openai",