From 067ff321e52606a3b6be9aafe0b27596d369fb38 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Tue, 1 Sep 2026 13:05:44 +0100 Subject: [PATCH 1/3] fix: pass aggregatedCounts only when all component queries succeed Signed-off-by: Marek Dano --- .../VirtualServerDetailsPanel.test.tsx | 84 +++++++++++++++++++ .../gateways/VirtualServerDetailsPanel.tsx | 42 ++++++---- 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index 0993320..830ebb0 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -567,6 +567,90 @@ describe("VirtualServerDetailsPanel test connection tab", () => { expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); }); + it("suppresses the mismatch banner when one component query fails", async () => { + // If the resources query errors while tools succeeds, the aggregate must + // not silently treat the failed query as a count of 0 — that would flag + // a mismatch against a handshake that actually agrees. + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ tools: [{ id: "t1", name: "tool-1", originalName: "tool-1" }] }), + ), + http.get("*/servers/:id/resources", () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 1 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/^connection test$/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); + }); + + it("suppresses the mismatch banner when all component queries fail, even with a disabled component", async () => { + // When every component query fails, the panel would otherwise fall back + // to buildComponentItems(server), which carries no `enabled` field and + // can't exclude disabled components from the aggregate. + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => HttpResponse.json({ error: "boom" }, { status: 500 })), + http.get("*/servers/:id/resources", () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + http.get("*/servers/:id/prompts", () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 1 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/^connection test$/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); + }); + it("resets to the try-it tab when a new server is selected", async () => { const user = userEvent.setup(); const { rerender } = render( diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index b1ea443..4cbd637 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -231,23 +231,29 @@ export function VirtualServerDetailsPanel({ // Fetch components data - only when panel is open and server exists const fetchEnabled = open && Boolean(server?.id); - const { data: toolsData, isLoading: toolsLoading } = useQuery<{ tools: Tool[] }>(toolsPath, { + const { + data: toolsData, + isLoading: toolsLoading, + error: toolsError, + } = useQuery<{ tools: Tool[] }>(toolsPath, { enabled: fetchEnabled, }); - const { data: resourcesData, isLoading: resourcesLoading } = useQuery<{ resources: Resource[] }>( - resourcesPath, - { - enabled: fetchEnabled, - }, - ); + const { + data: resourcesData, + isLoading: resourcesLoading, + error: resourcesError, + } = useQuery<{ resources: Resource[] }>(resourcesPath, { + enabled: fetchEnabled, + }); - const { data: promptsData, isLoading: promptsLoading } = useQuery<{ prompts: Prompt[] }>( - promptsPath, - { - enabled: fetchEnabled, - }, - ); + const { + data: promptsData, + isLoading: promptsLoading, + error: promptsError, + } = useQuery<{ prompts: Prompt[] }>(promptsPath, { + enabled: fetchEnabled, + }); const fetchedComponents = useMemo((): ComponentWithType[] => { const tools = Array.isArray(toolsData) ? toolsData : toolsData?.tools || []; @@ -292,14 +298,20 @@ export function VirtualServerDetailsPanel({ // MCP endpoint, which only ever see enabled components — so a disabled // component here must be excluded too, or a server with one disabled tool // would show a permanent, spurious mismatch. + // + // Only computed from `fetchedComponents` (never the `buildComponentItems` + // fallback, which carries no `enabled` field and can't be filtered) and + // only when all three component queries succeeded — a failed query would + // otherwise silently contribute 0 and trigger a false mismatch. const aggregatedComponentCounts = useMemo(() => { + if (toolsError || resourcesError || promptsError) return undefined; const counts: Record = { tools: 0, resources: 0, prompts: 0 }; - for (const component of allComponents) { + for (const component of fetchedComponents) { if (component.enabled === false) continue; counts[component.type] = (counts[component.type] ?? 0) + 1; } return counts; - }, [allComponents]); + }, [fetchedComponents, toolsError, resourcesError, promptsError]); const sourceIds = useMemo( () => From f318b33e18d82af6c85cd39ca4622b2a30ad724e Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Thu, 3 Sep 2026 10:34:04 +0100 Subject: [PATCH 2/3] fix: address comments Signed-off-by: Marek Dano --- .../gateways/VirtualServerDetailsPanel.test.tsx | 2 +- .../gateways/VirtualServerDetailsPanel.tsx | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index 830ebb0..6ef3eab 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -583,7 +583,7 @@ describe("VirtualServerDetailsPanel test connection tab", () => { HttpResponse.json({ success: true, latencyMs: 10, - componentCounts: { tools: 1 }, + componentCounts: { tools: 1, resources: 2 }, }), ), ); diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 4cbd637..3e1ffba 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -302,16 +302,29 @@ export function VirtualServerDetailsPanel({ // Only computed from `fetchedComponents` (never the `buildComponentItems` // fallback, which carries no `enabled` field and can't be filtered) and // only when all three component queries succeeded — a failed query would - // otherwise silently contribute 0 and trigger a false mismatch. + // otherwise silently contribute 0 and trigger a false mismatch. Also + // withheld until all three have resolved at least once, since `useQuery` + // never clears `data` on a later error and the pre-resolve state would + // otherwise compare a `{ tools: 0, resources: 0, prompts: 0 }` aggregate + // against a handshake that already has real counts. const aggregatedComponentCounts = useMemo(() => { if (toolsError || resourcesError || promptsError) return undefined; + if (!toolsData || !resourcesData || !promptsData) return undefined; const counts: Record = { tools: 0, resources: 0, prompts: 0 }; for (const component of fetchedComponents) { if (component.enabled === false) continue; counts[component.type] = (counts[component.type] ?? 0) + 1; } return counts; - }, [fetchedComponents, toolsError, resourcesError, promptsError]); + }, [ + fetchedComponents, + toolsError, + resourcesError, + promptsError, + toolsData, + resourcesData, + promptsData, + ]); const sourceIds = useMemo( () => From e0249e10bb19406b57a0730de0839463e3e92604 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Fri, 4 Sep 2026 14:20:43 +0100 Subject: [PATCH 3/3] fix: add unit test covering the loading-window Signed-off-by: Marek Dano --- .../VirtualServerDetailsPanel.test.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index 6ef3eab..e3d44f3 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { http, HttpResponse } from "msw"; +import { http, HttpResponse, delay } from "msw"; import { server as mswServer } from "@/test/mocks/server"; import { renderWithProviders as render } from "@/test/test-utils"; import { VirtualServerDetailsPanel } from "./VirtualServerDetailsPanel"; @@ -651,6 +651,45 @@ describe("VirtualServerDetailsPanel test connection tab", () => { expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); }); + it("suppresses the mismatch banner while a component query is still loading", async () => { + // Before all three queries have resolved, the aggregate must not stand + // in as {0,0,0} and get compared against the handshake — that flags a + // spurious mismatch during the loading window, which is longest when a + // query hangs rather than erroring outright. + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", async () => { + await delay("infinite"); + return HttpResponse.json({ tools: [] }); + }), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 1 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/^connection test$/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); + }); + it("resets to the try-it tab when a new server is selected", async () => { const user = userEvent.setup(); const { rerender } = render(