From 5a2c8b37315f0f5cc856fc7ba6b9f72a1cb90688 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Sat, 22 Aug 2026 17:55:27 +0100 Subject: [PATCH] feat: add Test connection tab to virtual server details drawer Signed-off-by: Marek Dano --- src/api/servers.ts | 3 + .../VirtualServerDetailsPanel.test.tsx | 146 +++++ .../gateways/VirtualServerDetailsPanel.tsx | 404 ++++++++------ .../servers/HandshakeTestPanel.test.tsx | 376 +++++++++++++ src/components/servers/HandshakeTestPanel.tsx | 507 ++++++++++++++++++ 5 files changed, 1259 insertions(+), 177 deletions(-) create mode 100644 src/components/servers/HandshakeTestPanel.test.tsx create mode 100644 src/components/servers/HandshakeTestPanel.tsx diff --git a/src/api/servers.ts b/src/api/servers.ts index b131491..4f7b6b9 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -140,6 +140,9 @@ export const serversApi = { * * Tries the stateless server/discover method (MCP 2026-07-28+) first and * falls back to a stateful initialize round-trip for earlier specs. + * + * Calls POST /v1/mcp-servers/test-handshake. Returns a structured + * GatewayHandshakeResponse describing the negotiation outcome. */ testHandshake: ( request: GatewayHandshakeRequest, diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index 63db860..38c7e4d 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -399,3 +399,149 @@ describe("VirtualServerDetailsPanel render variants", () => { expect(allTab).toHaveAttribute("aria-selected", "true"); }); }); + +describe("VirtualServerDetailsPanel test connection tab", () => { + const HANDSHAKE_ENDPOINT = "*/v1/mcp-servers/test-handshake"; + + beforeEach(() => { + mswServer.use( + http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [] })), + http.get("*/servers/:id/resources", () => HttpResponse.json({ resources: [] })), + http.get("*/servers/:id/prompts", () => HttpResponse.json({ prompts: [] })), + ); + }); + + it("renders the Components and Test connection top-level tabs", async () => { + render( + , + ); + + expect(await screen.findByRole("tab", { name: "Components" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Test connection" })).toBeInTheDocument(); + }); + + it("switches to the test panel and shows the handshake form", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + + expect(screen.getByRole("button", { name: /^test connection$/i })).toBeInTheDocument(); + expect(screen.getByText(/run a test to see the result here/i)).toBeInTheDocument(); + }); + + it("runs a handshake and displays a successful result", async () => { + const user = userEvent.setup(); + mswServer.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 42, + serverName: "Test MCP", + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/latency: 42 ms/i)).toBeInTheDocument(); + }); + + it("flags a component-count mismatch using the panel's own aggregated counts", async () => { + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ tools: [{ id: "t1", name: "tool-1", originalName: "tool-1" }] }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 0 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + expect( + await screen.findByText(/counts don.t match the virtual server.s aggregate/i), + ).toBeInTheDocument(); + }); + + it("resets to the components tab when a new server is selected", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + expect(screen.getByRole("button", { name: /^test connection$/i })).toBeInTheDocument(); + + // Simulate opening a different server — the panel resets to Components. + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Components" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + }); +}); diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 39dce7a..a244d03 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -12,6 +12,8 @@ import { Search, Wrench, } from "lucide-react"; +import { HandshakeTestPanel } from "@/components/servers/HandshakeTestPanel"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VisibilityInfoPopover, getVisibilityIcon, @@ -42,6 +44,12 @@ const COMPONENT_FILTER_OPTIONS: Array<{ value: ComponentFilter; labelId: string { value: "prompts", labelId: "gateways.details.filter.prompts" }, ]; +type TopTab = "components" | "test"; + +// Segmented-control styling shared with MCPServerDetailsPanel +const SEGMENTED_TRIGGER_CLASS = + "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; + interface Tool { id: string; name: string; @@ -142,6 +150,7 @@ export function VirtualServerDetailsPanel({ const tagFallback = intl.formatMessage({ id: "gateways.details.tagFallback" }); const notSyncedYet = intl.formatMessage({ id: "gateways.card.notSyncedYet" }); const tags = (server?.tags ?? []).map((tag, index) => getTagDisplay(tag, index, tagFallback)); + const [topTab, setTopTab] = useState("components"); const [sourceFilter, setSourceFilter] = useState("all"); const [componentFilter, setComponentFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -272,6 +281,16 @@ export function VirtualServerDetailsPanel({ const allComponents = fetchedComponents.length > 0 ? fetchedComponents : fallbackComponents; + // The virtual server's own aggregated component counts, used to flag a + // mismatch against what the handshake test itself reports. + const aggregatedComponentCounts = useMemo(() => { + const counts: Record = { tools: 0, resources: 0, prompts: 0 }; + for (const component of allComponents) { + counts[component.type] = (counts[component.type] ?? 0) + 1; + } + return counts; + }, [allComponents]); + const sourceIds = useMemo( () => Array.from( @@ -309,9 +328,10 @@ export function VirtualServerDetailsPanel({ const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; - // Reset filter and search when the panel opens or the selected server changes. + // Reset tab, filter and search when the panel opens or the selected server changes. useEffect(() => { if (!open) return; + setTopTab("components"); setSourceFilter("all"); setComponentFilter("all"); setSearchQuery(""); @@ -440,196 +460,226 @@ export function VirtualServerDetailsPanel({
- {(sourcesLoading || sourceTabs.length > 0) && ( -
- {[ - { - id: "all", - label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), - }, - ...sourceTabs, - ].map((source, index, sources) => { - const isSelected = sourceFilter === source.id; + setTopTab(v as TopTab)} + aria-label="Virtual server details view" + > + + + {intl.formatMessage({ id: "gateways.details.components" })} + + + {intl.formatMessage({ id: "gateways.card.testConnection" })} + + + + + + + + + {(sourcesLoading || sourceTabs.length > 0) && ( +
+ {[ + { + id: "all", + label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), + }, + ...sourceTabs, + ].map((source, index, sources) => { + const isSelected = sourceFilter === source.id; + + return ( + + ); + })} +
+ )} - return ( +
+
+ {COMPONENT_FILTER_OPTIONS.map((option) => ( + + ))} +
+
- ); - })} -
- )} - -
-
- {COMPONENT_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- - 0 ? 0 : -1} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onFocus={() => setIsSearchExpanded(true)} - onBlur={() => setIsSearchExpanded(searchQuery.length > 0)} - placeholder={isSearchExpanded || searchQuery.length > 0 ? "Search..." : ""} - className={cn( - "h-8 rounded-md border-border bg-muted/50 text-sm shadow-none transition-[width,padding,color,background-color,border-color] duration-200 ease-out placeholder:text-muted-foreground focus-visible:bg-background", - isSearchExpanded || searchQuery.length > 0 - ? "w-48 px-3 text-foreground" - : "w-0 px-0 text-transparent caret-foreground border-transparent", - )} - /> -
-
- - {error && ( -
- {error.message} -
- )} - -
- {componentsLoading && ( -
-
- )} - {!componentsLoading && - visibleComponents.map((component) => { - const title = component.title; - const identifier = getComponentIdentifier(component); + {error && ( +
+ {error.message} +
+ )} - return ( +
+ {componentsLoading && (
- - - {getComponentIcon(component.type)} - - {getComponentLabel(component.type)} - - {title ? ( - <> - {title} - - {identifier} - - - - ) : ( - <> - - {identifier} - - -
- ); - })} + )} - {!componentsLoading && visibleComponents.length === 0 && ( -
- No {componentFilter === "all" ? "components" : componentFilter} found + {!componentsLoading && + visibleComponents.map((component) => { + const title = component.title; + const identifier = getComponentIdentifier(component); + + return ( +
+ + + {getComponentIcon(component.type)} + + {getComponentLabel(component.type)} + + {title ? ( + <> + + {title} + + + {identifier} + + + + ) : ( + <> + + {identifier} + + +
+ ); + })} + + {!componentsLoading && visibleComponents.length === 0 && ( +
+ No {componentFilter === "all" ? "components" : componentFilter} found +
+ )}
- )} -
+ +