-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(site): show network calls list on AI session detail #27426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
|
Comment on lines
+36
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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(); | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: JSDoc says Something like:
|
||
| 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> | ||
|
SasSwart marked this conversation as resolved.
|
||
| </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> | ||
| ); | ||
Uh oh!
There was an error while loading. Please reload this page.