Skip to content

Commit c101579

Browse files
cliffhallclaude
andcommitted
feat(network): add Network screen surfacing backend fetch log (#1355)
Routes `FetchRequestLogState` to a new Network tab so users can inspect HTTP-layer request/response headers, status, duration, and category (auth vs transport) of every fetch the backend makes for an active session. Adds `NetworkControls` + `NetworkStreamPanel` + `NetworkEntry` groups, a `monoBreak` Text theme variant for long header values, and wires onClearNetwork / onExportNetwork (JSON download) from `App.tsx`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 491a522 commit c101579

19 files changed

Lines changed: 1297 additions & 0 deletions

clients/web/src/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useManagedResourceTemplates } from "@inspector/core/react/useManagedRes
3838
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
3939
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
4040
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
41+
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js";
4142
import { InspectorView } from "./components/views/InspectorView/InspectorView";
4243
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
4344
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
@@ -268,6 +269,7 @@ function App() {
268269
resourceSubscriptionsState,
269270
);
270271
const { messages } = useMessageLog(messageLogState);
272+
const { fetchRequests } = useFetchRequestLog(fetchRequestLogState);
271273

272274
// Capture observed handshake latency at the connecting → connected edge.
273275
// Reset when the status leaves "connected" so the next connect starts
@@ -671,6 +673,15 @@ function App() {
671673
messageLogState?.clearMessages();
672674
}, [messageLogState]);
673675

676+
const onClearNetwork = useCallback(() => {
677+
fetchRequestLogState?.clearFetchRequests();
678+
}, [fetchRequestLogState]);
679+
680+
const onExportNetwork = useCallback(() => {
681+
if (fetchRequests.length === 0) return;
682+
downloadJsonFile("network.json", JSON.stringify(fetchRequests, null, 2));
683+
}, [fetchRequests]);
684+
674685
// Action stubs — these UI affordances exist but require additional
675686
// wiring (server CRUD, history pinning, app sandbox round-trip, log
676687
// export). Tracked separately; the noop keeps the prop interface
@@ -857,6 +868,7 @@ function App() {
857868
logs={logs}
858869
tasks={tasks}
859870
history={messages}
871+
network={fetchRequests}
860872
toolCallState={toolCallState}
861873
getPromptState={getPromptState}
862874
readResourceState={effectiveReadResourceState}
@@ -910,6 +922,8 @@ function App() {
910922
onExportHistory={todoNoop}
911923
onReplayHistory={todoNoop}
912924
onTogglePinHistory={todoNoop}
925+
onClearNetwork={onClearNetwork}
926+
onExportNetwork={onExportNetwork}
913927
onSelectApp={todoNoop}
914928
onOpenApp={todoNoop}
915929
onCloseApp={todoNoop}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { fn } from "storybook/test";
3+
import { NetworkControls } from "./NetworkControls";
4+
5+
const meta: Meta<typeof NetworkControls> = {
6+
title: "Groups/NetworkControls",
7+
component: NetworkControls,
8+
args: {
9+
filterText: "",
10+
visibleCategories: { auth: true, transport: true },
11+
onFilterChange: fn(),
12+
onToggleCategory: fn(),
13+
onToggleAllCategories: fn(),
14+
},
15+
};
16+
17+
export default meta;
18+
type Story = StoryObj<typeof NetworkControls>;
19+
20+
export const AllVisible: Story = {};
21+
22+
export const NoneVisible: Story = {
23+
args: {
24+
visibleCategories: { auth: false, transport: false },
25+
},
26+
};
27+
28+
export const OnlyAuth: Story = {
29+
args: {
30+
visibleCategories: { auth: true, transport: false },
31+
},
32+
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import userEvent from "@testing-library/user-event";
3+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
4+
import { NetworkControls } from "./NetworkControls";
5+
6+
const baseProps = {
7+
filterText: "",
8+
visibleCategories: { auth: true, transport: true } as const,
9+
onFilterChange: vi.fn(),
10+
onToggleCategory: vi.fn(),
11+
onToggleAllCategories: vi.fn(),
12+
};
13+
14+
describe("NetworkControls", () => {
15+
it("renders the title and inputs", () => {
16+
renderWithMantine(<NetworkControls {...baseProps} />);
17+
expect(screen.getByText("Network")).toBeInTheDocument();
18+
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
19+
expect(screen.getByRole("button", { name: "auth" })).toBeInTheDocument();
20+
expect(
21+
screen.getByRole("button", { name: "transport" }),
22+
).toBeInTheDocument();
23+
});
24+
25+
it("fires onFilterChange when the user types", async () => {
26+
const user = userEvent.setup();
27+
const onFilterChange = vi.fn();
28+
renderWithMantine(
29+
<NetworkControls {...baseProps} onFilterChange={onFilterChange} />,
30+
);
31+
await user.type(screen.getByPlaceholderText("Search..."), "x");
32+
expect(onFilterChange).toHaveBeenLastCalledWith("x");
33+
});
34+
35+
it("fires onToggleCategory with inverted visibility when clicked", async () => {
36+
const user = userEvent.setup();
37+
const onToggleCategory = vi.fn();
38+
renderWithMantine(
39+
<NetworkControls {...baseProps} onToggleCategory={onToggleCategory} />,
40+
);
41+
await user.click(screen.getByRole("button", { name: "auth" }));
42+
expect(onToggleCategory).toHaveBeenCalledWith("auth", false);
43+
});
44+
45+
it("toggles between Select All and Deselect All", () => {
46+
const { rerender } = renderWithMantine(<NetworkControls {...baseProps} />);
47+
expect(
48+
screen.getByRole("button", { name: "Deselect All" }),
49+
).toBeInTheDocument();
50+
rerender(
51+
<NetworkControls
52+
{...baseProps}
53+
visibleCategories={{ auth: false, transport: false }}
54+
/>,
55+
);
56+
expect(
57+
screen.getByRole("button", { name: "Select All" }),
58+
).toBeInTheDocument();
59+
});
60+
61+
it("fires onToggleAllCategories when the toggle button is clicked", async () => {
62+
const user = userEvent.setup();
63+
const onToggleAllCategories = vi.fn();
64+
renderWithMantine(
65+
<NetworkControls
66+
{...baseProps}
67+
onToggleAllCategories={onToggleAllCategories}
68+
/>,
69+
);
70+
await user.click(screen.getByRole("button", { name: "Deselect All" }));
71+
expect(onToggleAllCategories).toHaveBeenCalledTimes(1);
72+
});
73+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
Button,
3+
Group,
4+
Stack,
5+
Text,
6+
TextInput,
7+
Title,
8+
UnstyledButton,
9+
} from "@mantine/core";
10+
import type { FetchRequestCategory } from "@inspector/core/mcp/types.js";
11+
12+
const NETWORK_CATEGORIES: FetchRequestCategory[] = ["auth", "transport"];
13+
14+
const CATEGORY_COLORS: Record<FetchRequestCategory, string> = {
15+
auth: "violet",
16+
transport: "blue",
17+
};
18+
19+
const SubtleButton = Button.withProps({
20+
variant: "subtle",
21+
size: "xs",
22+
});
23+
24+
export interface NetworkControlsProps {
25+
filterText: string;
26+
visibleCategories: Record<FetchRequestCategory, boolean>;
27+
onFilterChange: (text: string) => void;
28+
onToggleCategory: (category: FetchRequestCategory, visible: boolean) => void;
29+
onToggleAllCategories: () => void;
30+
}
31+
32+
export function NetworkControls({
33+
filterText,
34+
visibleCategories,
35+
onFilterChange,
36+
onToggleCategory,
37+
onToggleAllCategories,
38+
}: NetworkControlsProps) {
39+
const allSelected = NETWORK_CATEGORIES.every((c) => visibleCategories[c]);
40+
return (
41+
<Stack gap="md">
42+
<Title order={4}>Network</Title>
43+
44+
<TextInput
45+
placeholder="Search..."
46+
value={filterText}
47+
onChange={(e) => onFilterChange(e.currentTarget.value)}
48+
/>
49+
50+
<Group justify="space-between">
51+
<Title order={5}>Filter by Category</Title>
52+
<SubtleButton onClick={onToggleAllCategories}>
53+
{allSelected ? "Deselect All" : "Select All"}
54+
</SubtleButton>
55+
</Group>
56+
<Stack gap="xs">
57+
{NETWORK_CATEGORIES.map((category) => {
58+
const active = visibleCategories[category];
59+
return (
60+
<UnstyledButton
61+
key={category}
62+
w="100%"
63+
p="sm"
64+
variant="listItem"
65+
bg={active ? "var(--mantine-primary-color-light)" : undefined}
66+
onClick={() => onToggleCategory(category, !active)}
67+
>
68+
<Text c={CATEGORY_COLORS[category]} ta="center" fw={500}>
69+
{category}
70+
</Text>
71+
</UnstyledButton>
72+
);
73+
})}
74+
</Stack>
75+
</Stack>
76+
);
77+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js";
3+
import { NetworkEntry } from "./NetworkEntry";
4+
5+
const meta: Meta<typeof NetworkEntry> = {
6+
title: "Groups/NetworkEntry",
7+
component: NetworkEntry,
8+
};
9+
10+
export default meta;
11+
type Story = StoryObj<typeof NetworkEntry>;
12+
13+
const transportEntry: FetchRequestEntry = {
14+
id: "n-1",
15+
timestamp: new Date("2026-03-17T10:30:00Z"),
16+
method: "POST",
17+
url: "https://example.com/mcp",
18+
requestHeaders: {
19+
"content-type": "application/json",
20+
"x-test": "hello",
21+
},
22+
requestBody: '{"jsonrpc":"2.0","method":"initialize","id":1}',
23+
responseStatus: 200,
24+
responseStatusText: "OK",
25+
responseHeaders: { "content-type": "application/json" },
26+
responseBody: '{"jsonrpc":"2.0","id":1,"result":{}}',
27+
duration: 45,
28+
category: "transport",
29+
};
30+
31+
const authEntry: FetchRequestEntry = {
32+
id: "n-2",
33+
timestamp: new Date("2026-03-17T10:30:05Z"),
34+
method: "POST",
35+
url: "https://example.com/oauth/token",
36+
requestHeaders: { "content-type": "application/x-www-form-urlencoded" },
37+
requestBody: "grant_type=authorization_code&code=abc",
38+
responseStatus: 200,
39+
responseStatusText: "OK",
40+
responseHeaders: { "content-type": "application/json" },
41+
responseBody: '{"access_token":"x","token_type":"bearer"}',
42+
duration: 120,
43+
category: "auth",
44+
};
45+
46+
const errorEntry: FetchRequestEntry = {
47+
id: "n-3",
48+
timestamp: new Date("2026-03-17T10:30:10Z"),
49+
method: "POST",
50+
url: "https://example.com/mcp",
51+
requestHeaders: { "content-type": "application/json" },
52+
responseStatus: 500,
53+
responseStatusText: "Internal Server Error",
54+
responseHeaders: { "content-type": "text/plain" },
55+
responseBody: "Unhandled exception",
56+
duration: 1200,
57+
category: "transport",
58+
};
59+
60+
const transportError: FetchRequestEntry = {
61+
id: "n-4",
62+
timestamp: new Date("2026-03-17T10:30:15Z"),
63+
method: "POST",
64+
url: "https://example.com/mcp",
65+
requestHeaders: {},
66+
error: "fetch failed: ECONNREFUSED",
67+
category: "transport",
68+
};
69+
70+
export const TransportSuccessCollapsed: Story = {
71+
args: { entry: transportEntry, isListExpanded: false },
72+
};
73+
74+
export const TransportSuccessExpanded: Story = {
75+
args: { entry: transportEntry, isListExpanded: true },
76+
};
77+
78+
export const AuthSuccess: Story = {
79+
args: { entry: authEntry, isListExpanded: true },
80+
};
81+
82+
export const HttpError: Story = {
83+
args: { entry: errorEntry, isListExpanded: true },
84+
};
85+
86+
export const FetchError: Story = {
87+
args: { entry: transportError, isListExpanded: true },
88+
};

0 commit comments

Comments
 (0)