mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: show network request summary on AI session detail card (#27418)
Frontend for the AI session network summary. Adds Network calls, Blocked network requests, and Top domains rows to the Session summary card on the individual AI session detail page, driven by the network fields on the session threads response. Renders "Disabled" when network monitoring was not active and "No activity" when there were no calls. Covered by Storybook stories for each state. ### 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 2. #27418 — frontend summary rows 3. #27425 — backend per-call list `network_call_logs` 4. #27426 — frontend network-calls panel Refs AIGOV-463 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Cian Johnston
parent
841a1765f7
commit
3f3fd1c4d7
Generated
+1
-1
@@ -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"
|
||||
|
||||
Generated
+1
-1
@@ -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"
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -48,12 +48,12 @@ not just what was called.
|
||||
|
||||
The sessions page (`http://<deployment-url>/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.
|
||||
|
||||

|
||||
|
||||
## Conducting a forensic audit
|
||||
|
||||
Generated
+16
-16
@@ -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
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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".
|
||||
|
||||
@@ -86,7 +86,7 @@ export const ListSessionsPageView: FC<ListSessionsPageViewProps> = ({
|
||||
<TableHead className="text-nowrap">Provider</TableHead>
|
||||
<TableHead className="text-nowrap">Client</TableHead>
|
||||
<TableHead className="text-nowrap">In/Out Tokens</TableHead>
|
||||
<TableHead className="text-nowrap">Network Calls</TableHead>
|
||||
<TableHead className="text-nowrap">Network Requests</TableHead>
|
||||
<TableHead className="flex items-center flex-nowrap gap-1">
|
||||
Threads
|
||||
<ThreadTooltip>
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<NetworkCallBadgesProps> = ({ summary }) => {
|
||||
if (!summary) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap text-content-secondary">
|
||||
Disabled
|
||||
<InfoTooltip message="Network call monitoring was not active for this session." />
|
||||
</span>
|
||||
);
|
||||
return <NetworkMonitoringDisabled />;
|
||||
}
|
||||
|
||||
if (summary.total === 0) {
|
||||
return (
|
||||
<span className="whitespace-nowrap text-content-secondary">
|
||||
No activity
|
||||
</span>
|
||||
);
|
||||
return <NetworkNoActivity />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -63,7 +57,7 @@ export const NetworkCallBadges: FC<NetworkCallBadgesProps> = ({ summary }) => {
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-secondary">Total calls</span>
|
||||
<span className="text-content-secondary">Total requests</span>
|
||||
<span>{summary.total.toLocaleString("en-US")}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -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 = () => (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap text-content-secondary">
|
||||
Disabled
|
||||
<InfoTooltip message="Network request monitoring was not active for this session." />
|
||||
</span>
|
||||
);
|
||||
|
||||
export const NetworkNoActivity: FC = () => (
|
||||
<span className="whitespace-nowrap text-content-secondary">No activity</span>
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
// 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 = <NetworkMonitoringDisabled />;
|
||||
} else if (networkCalls.total === 0) {
|
||||
networkCallsValue = <NetworkNoActivity />;
|
||||
} else {
|
||||
networkCallsValue = (
|
||||
<Badge>{networkCalls.total.toLocaleString("en-US")}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="text-sm text-content-secondary m-0 flex flex-col gap-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -163,6 +196,53 @@ export const SessionSummaryTable = ({
|
||||
<Badge>{toolCallCount}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="shrink-0 font-normal whitespace-nowrap">
|
||||
Network requests
|
||||
</dt>
|
||||
<dd className="ml-4 min-w-0 truncate text-content-primary">
|
||||
{networkCallsValue}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{networkCalls !== undefined && networkCalls.total > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="shrink-0 font-normal whitespace-nowrap">
|
||||
Blocked network requests
|
||||
</dt>
|
||||
<dd className="ml-4 min-w-0 truncate text-content-primary">
|
||||
{networkCalls.blocked > 0 ? (
|
||||
<Badge svgSize="xs" className="gap-1 text-content-warning">
|
||||
<BanIcon className="flex-shrink-0" />
|
||||
{networkCalls.blocked.toLocaleString("en-US")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge>{networkCalls.blocked.toLocaleString("en-US")}</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkDomains !== undefined && (
|
||||
<div className="flex items-start justify-between">
|
||||
<dt className="shrink-0 font-normal whitespace-nowrap mt-px">
|
||||
Top domains
|
||||
</dt>
|
||||
<dd className="ml-4 min-w-0 text-content-primary text-right">
|
||||
<div className="truncate" title={networkDomains.topDomain.domain}>
|
||||
{networkDomains.topDomain.domain}
|
||||
</div>
|
||||
{networkDomains.totalCount > 1 && (
|
||||
<div className="text-content-secondary text-xs">
|
||||
+{(networkDomains.totalCount - 1).toLocaleString("en-US")} more
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,6 +75,10 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<nav className="mb-6">
|
||||
@@ -115,6 +119,13 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
|
||||
threadCount={threads.length}
|
||||
toolCallCount={toolCallCount}
|
||||
tokenUsageMetadata={session.token_usage_summary.metadata}
|
||||
networkCalls={session.network_calls}
|
||||
networkDomains={
|
||||
topDomain && {
|
||||
topDomain,
|
||||
totalCount: session.network_domain_count ?? 1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user