feat(site): show network calls list on AI session detail (#27426)

Renders the per-call network calls as a collapsible panel above the
session threads, using the `network_call_logs` field added to the
threads API.

Each row shows the request method, allowed/blocked status, URL, and
timestamp, and expands to show the protocol, matched rule, and full
detail. The header shows the total count and a blocked-count badge from
the session summary; when the server caps the list, the panel notes how
many calls are shown. The panel is omitted when the session did not pass
through Agent Firewall.

### 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 (base `main`)
2. #27418 — frontend summary rows (base #27417)
3. #27425 — backend per-call list `network_call_logs` (base #27418)
4. #27426 — frontend network-calls panel (base #27425)

Refs AIGOV-464

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sas Swart
2026-08-04 08:30:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a779320d87
commit a60f393773
6 changed files with 407 additions and 1 deletions
@@ -134,6 +134,8 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
<SessionTimeline
initiator={session.initiator}
threads={threads}
networkCallSummary={session.network_calls}
networkCalls={session.network_call_logs ?? []}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent } from "storybook/test";
import { MockAIBridgeSessionNetworkCalls } from "#/testHelpers/entities";
import { NetworkCallsTable } from "./NetworkCallsTable";
const meta: Meta<typeof NetworkCallsTable> = {
title: "pages/AIBridgePage/NetworkCallsTable",
component: NetworkCallsTable,
args: {
summary: { total: 4, blocked: 2 },
calls: MockAIBridgeSessionNetworkCalls,
},
};
export default meta;
type Story = StoryObj<typeof NetworkCallsTable>;
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();
},
};
@@ -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<NetworkCallsTableProps> = ({
summary,
calls,
}) => (
<Collapsible defaultOpen className="border border-solid rounded-md">
<div className="flex items-center justify-between gap-2 px-2 py-1">
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex items-center gap-4 p-1 bg-transparent border-none cursor-pointer text-sm font-normal text-content-secondary"
>
<ChevronRightIcon className="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
<span>Network calls ({summary.total.toLocaleString("en-US")})</span>
</button>
</CollapsibleTrigger>
{summary.blocked > 0 && (
<Badge svgSize="xs" className="gap-1 text-content-warning">
<BanIcon className="flex-shrink-0" />
<span className="sr-only">Blocked network calls: </span>
{summary.blocked.toLocaleString("en-US")}
</Badge>
)}
</div>
<CollapsibleContent className="border-0 border-t border-solid">
<NetworkCallsList summary={summary} calls={calls} />
</CollapsibleContent>
</Collapsible>
);
const NetworkCallsList: FC<NetworkCallsTableProps> = ({ summary, calls }) => {
if (calls.length === 0) {
return (
<p className="m-0 px-4 py-3 text-sm font-normal text-content-secondary">
No network calls were recorded for this session.
</p>
);
}
const hiddenCount = summary.total - calls.length;
return (
<>
<ul className="m-0 p-0 list-none">
{calls.map((call) => (
<NetworkCallRow key={call.id} call={call} />
))}
</ul>
{hiddenCount > 0 && (
<p className="m-0 px-4 py-2 text-xs font-normal text-content-secondary border-0 border-t border-solid">
Showing the first {calls.length.toLocaleString("en-US")} of{" "}
{summary.total.toLocaleString("en-US")} network calls.
</p>
)}
</>
);
};
interface NetworkCallRowProps {
call: AgentFirewallLog;
}
const NetworkCallRow: FC<NetworkCallRowProps> = ({ call }) => {
const timestamp = formatDateTime(new Date(call.created_at));
return (
<li className="border-0 border-t border-solid first:border-t-0">
<Collapsible>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex items-center gap-3 w-full px-2 py-2 text-left bg-transparent border-none cursor-pointer hover:bg-surface-secondary"
>
<ChevronRightIcon className="size-3.5 flex-shrink-0 text-content-secondary transition-transform group-data-[state=open]:rotate-90" />
{call.method && (
<Badge size="sm" className="flex-shrink-0 font-mono">
{call.method}
</Badge>
)}
<NetworkCallStatusBadge allowed={call.allowed} />
<span
className="flex-1 min-w-0 truncate font-mono text-xs text-content-primary"
title={call.detail}
>
{call.detail || "N/A"}
</span>
<span className="hidden md:flex items-center gap-2 flex-shrink-0 text-sm font-normal text-content-secondary">
Timestamp
<span className="font-mono text-xs text-content-primary">
{timestamp}
</span>
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<dl className="flex flex-col gap-2 m-0 px-9 pb-3 text-sm font-normal text-content-secondary">
<NetworkCallDetailRow label="URL">
<span
className="min-w-0 truncate font-mono text-xs text-content-primary"
title={call.detail}
>
{call.detail || "N/A"}
</span>
{call.detail && (
<CopyButton text={call.detail} label="Copy network call URL" />
)}
</NetworkCallDetailRow>
<NetworkCallDetailRow label="Protocol">
<span className="font-mono text-xs text-content-primary">
{call.proto || "N/A"}
</span>
</NetworkCallDetailRow>
<NetworkCallDetailRow label="Matched rule">
<span
className="min-w-0 truncate font-mono text-xs text-content-primary"
title={call.matched_rule ?? undefined}
>
{call.matched_rule ?? "None"}
</span>
</NetworkCallDetailRow>
<NetworkCallDetailRow label="Timestamp">
<span className="font-mono text-xs text-content-primary">
{timestamp}
</span>
</NetworkCallDetailRow>
</dl>
</CollapsibleContent>
</Collapsible>
</li>
);
};
const NetworkCallStatusBadge: FC<{ allowed: boolean }> = ({ allowed }) =>
allowed ? (
<Badge size="sm" svgSize="xs" className="flex-shrink-0 gap-1">
<CheckIcon className="flex-shrink-0" />
Allowed
</Badge>
) : (
<Badge
size="sm"
svgSize="xs"
className="flex-shrink-0 gap-1 text-content-warning"
>
<BanIcon className="flex-shrink-0" />
Blocked
</Badge>
);
interface NetworkCallDetailRowProps {
label: string;
children: ReactNode;
}
const NetworkCallDetailRow: FC<NetworkCallDetailRowProps> = ({
label,
children,
}) => (
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 whitespace-nowrap">{label}</dt>
<dd className="flex items-center gap-2 m-0 min-w-0">{children}</dd>
</div>
);
@@ -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<typeof SessionTimeline> = {
args: {
initiator: MockSession.initiator,
threads: [mockThread],
networkCalls: [],
hasNextPage: false,
isFetchingNextPage: false,
onFetchNextPage: noop,
@@ -135,6 +140,22 @@ type Story = StoryObj<typeof SessionTimeline>;
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] },
};
@@ -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<ThreadItemProps> = ({ 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<SessionTimelineProps> = ({
initiator,
threads,
networkCallSummary,
networkCalls,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
@@ -524,6 +535,14 @@ export const SessionTimeline: FC<SessionTimelineProps> = ({
{/* left vertical line */}
</div>
<div className="row-start-5 col-start-2 col-span-4">
{networkCallSummary && (
<div className="mb-4">
<NetworkCallsTable
summary={networkCallSummary}
calls={networkCalls}
/>
</div>
)}
{/* threads */}
<div className="[&>.thread-gap:last-child]:hidden">
{threads.map((thread) => (
+48
View File
@@ -5560,6 +5560,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",