mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Claude Opus 4.8
parent
a779320d87
commit
a60f393773
@@ -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}
|
||||
|
||||
+127
@@ -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>
|
||||
);
|
||||
+22
-1
@@ -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) => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user