From d4ca62571e3ec76874bd33ca7ecda2c2d889e6eb Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Tue, 25 Aug 2026 15:11:48 +0100
Subject: [PATCH 1/8] feat: add catalog gateway actions
Signed-off-by: Vishu Bhatnagar
---
e2e/server-catalog.spec.ts | 15 +-
openapi.json | 14 +-
src/api/catalog.test.ts | 58 ++-
src/api/catalog.ts | 38 +-
src/api/client.ts | 12 +-
.../server-catalog/CatalogResults.test.tsx | 4 +
.../server-catalog/CatalogResults.tsx | 57 ++-
src/components/servers/ConfirmDialog.tsx | 7 +-
src/i18n/locales/en-US/mcpServer.json | 14 +
src/i18n/locales/es-ES/mcpServer.json | 14 +
src/i18n/locales/pt-BR/mcpServer.json | 14 +
src/pages/ServerCatalog.test.tsx | 156 +++++++-
src/pages/ServerCatalog.tsx | 345 +++++++++++++++++-
13 files changed, 715 insertions(+), 33 deletions(-)
diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts
index 27ece6bc..421bcfa7 100644
--- a/e2e/server-catalog.spec.ts
+++ b/e2e/server-catalog.spec.ts
@@ -70,6 +70,7 @@ async function mockCatalog(page: import("@playwright/test").Page, servers: Catal
test.describe("Server catalog page", () => {
test.beforeEach(async ({ page, apiMock }) => {
await apiMock.mockSession();
+ await apiMock.mockPermissions();
await page.addInitScript(() => {
sessionStorage.setItem("mcpgateway_token", "mock-token-12345");
@@ -224,7 +225,7 @@ test.describe("Server catalog page", () => {
await expect(page.getByRole("heading", { name: "Globalping" })).toBeVisible();
});
- test("adds an open server without refetching its card", async ({ page, apiMock }) => {
+ test("adds an open server then refreshes its gateway id", async ({ page }) => {
let registered = false;
let catalogCalls = 0;
let registerCalls = 0;
@@ -235,7 +236,13 @@ test.describe("Server catalog page", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
- servers: [{ ...OPEN_SERVER, is_registered: registered }],
+ servers: [
+ {
+ ...OPEN_SERVER,
+ is_registered: registered,
+ gateway_id: registered ? "gateway-public-notes" : null,
+ },
+ ],
total: 1,
categories: ["Productivity"],
auth_types: ["Open"],
@@ -276,7 +283,9 @@ test.describe("Server catalog page", () => {
await expect(page.getByRole("button", { name: "Add Public Notes" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "View Public Notes" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Actions for Public Notes" })).toBeVisible();
- expect(catalogCalls).toBe(catalogCallsBeforeAdd);
+ await expect.poll(() => catalogCalls).toBe(catalogCallsBeforeAdd + 1);
+ await page.getByRole("button", { name: "Actions for Public Notes" }).click();
+ await expect(page.getByRole("menuitem", { name: "Disconnect" })).toBeVisible();
});
test("removes a stale server and moves focus to its 404 notification", async ({ page }) => {
diff --git a/openapi.json b/openapi.json
index 30026ea8..7fb971f6 100644
--- a/openapi.json
+++ b/openapi.json
@@ -39786,6 +39786,18 @@
"description": "Whether server is already registered",
"default": false
},
+ "gateway_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Gateway Id",
+ "description": "ID of the caller-visible gateway matched to this catalog server"
+ },
"is_available": {
"type": "boolean",
"title": "Is Available",
@@ -53129,4 +53141,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/api/catalog.test.ts b/src/api/catalog.test.ts
index 51c3d7b5..6667f302 100644
--- a/src/api/catalog.test.ts
+++ b/src/api/catalog.test.ts
@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
-import { registerCatalogServer } from "./catalog";
+import {
+ disconnectCatalogGateway,
+ getGatewayImpactPreview,
+ registerCatalogServer,
+ testCatalogServer,
+} from "./catalog";
describe("registerCatalogServer", () => {
it("POSTs the URL-encoded catalog id through the API proxy", async () => {
@@ -27,4 +32,55 @@ describe("registerCatalogServer", () => {
message: "Registered",
});
});
+
+ it("DELETEs an encoded gateway ID and preserves async lifecycle metadata", async () => {
+ let requestPath = "";
+ server.use(
+ http.delete("*/api/v1/gateways/:gatewayId", ({ request }) => {
+ requestPath = new URL(request.url).pathname;
+ return HttpResponse.json(
+ { status: "deleting" },
+ { status: 202, headers: { "Retry-After": "2" } },
+ );
+ }),
+ );
+
+ const result = await disconnectCatalogGateway("gateway/id");
+
+ expect(requestPath).toBe("/api/v1/gateways/gateway%2Fid");
+ expect(result.status).toBe(202);
+ expect(result.headers.get("Retry-After")).toBe("2");
+ });
+
+ it("tests catalog URL with a safe fixed GET request", async () => {
+ let body: unknown;
+ server.use(
+ http.post("*/api/v1/mcp-servers/test", async ({ request }) => {
+ body = await request.json();
+ return HttpResponse.json({ statusCode: 204, latencyMs: 21 });
+ }),
+ );
+
+ await expect(testCatalogServer("https://catalog.example/mcp")).resolves.toEqual({
+ statusCode: 204,
+ latencyMs: 21,
+ });
+ expect(body).toEqual({ method: "GET", baseUrl: "https://catalog.example/mcp", path: "" });
+ });
+
+ it("gets only the backend-provided disconnect impact preview", async () => {
+ server.use(
+ http.get("*/api/v1/gateways/:gatewayId/impact-preview", () =>
+ HttpResponse.json({
+ gatewayId: "gateway-1",
+ servers: [{ id: "server-1", name: "Visible server" }],
+ }),
+ ),
+ );
+
+ await expect(getGatewayImpactPreview("gateway-1")).resolves.toEqual({
+ gatewayId: "gateway-1",
+ servers: [{ id: "server-1", name: "Visible server" }],
+ });
+ });
});
diff --git a/src/api/catalog.ts b/src/api/catalog.ts
index cdf08a0d..0e6971c3 100644
--- a/src/api/catalog.ts
+++ b/src/api/catalog.ts
@@ -1,5 +1,17 @@
import { api } from "./client";
-import type { CatalogServerRegisterResponse } from "@/generated/types";
+import type {
+ CatalogServerRegisterResponse,
+ GatewayRead,
+ GatewayTestRequest,
+ GatewayTestResponse,
+} from "@/generated/types";
+
+export interface GatewayImpactPreview {
+ gatewayId: string;
+ servers: Array<{ id: string; name: string }>;
+}
+
+export type CatalogGatewayDeleteResponse = GatewayRead | { status?: string; message?: string };
/** Register an open catalog entry through the authenticated BFF proxy. */
export async function registerCatalogServer(
@@ -9,3 +21,27 @@ export async function registerCatalogServer(
`/v1/catalog/${encodeURIComponent(catalogId)}/register`,
);
}
+
+/** Delete gateway selected by caller-visible catalog registration state. */
+export function disconnectCatalogGateway(gatewayId: string) {
+ return api.deleteWithMeta(
+ `/v1/gateways/${encodeURIComponent(gatewayId)}`,
+ );
+}
+
+/** Test the catalog server URL using stored credentials when backend has them. */
+export function testCatalogServer(url: string): Promise {
+ const request: GatewayTestRequest = {
+ method: "GET",
+ baseUrl: url,
+ path: "",
+ };
+ return api.post("/v1/mcp-servers/test", request);
+}
+
+/** Preview caller-visible virtual servers affected by disconnecting a gateway. */
+export function getGatewayImpactPreview(gatewayId: string): Promise {
+ return api.get(
+ `/v1/gateways/${encodeURIComponent(gatewayId)}/impact-preview`,
+ );
+}
diff --git a/src/api/client.ts b/src/api/client.ts
index d326a28b..6e0de44e 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -123,6 +123,7 @@ interface RequestOptions {
export interface ResponseWithMeta {
data: T;
status: number;
+ headers: Headers;
}
async function requestWithMeta(
@@ -192,11 +193,11 @@ async function requestWithMeta(
// 204 No Content
if (response.status === 204) {
- return { data: undefined as T, status: response.status };
+ return { data: undefined as T, status: response.status, headers: response.headers };
}
const data = (await response.json()) as T;
- return { data, status: response.status };
+ return { data, status: response.status, headers: response.headers };
}
async function request(path: string, options: RequestOptions = {}): Promise {
@@ -241,6 +242,13 @@ export const api = {
return requestWithMeta(path, { method: "POST", body, ...opts });
},
+ deleteWithMeta(
+ path: string,
+ opts?: Omit,
+ ): Promise> {
+ return requestWithMeta(path, { method: "DELETE", ...opts });
+ },
+
put(path: string, body?: unknown, opts?: Omit): Promise {
return request(path, { method: "PUT", body, ...opts });
},
diff --git a/src/components/server-catalog/CatalogResults.test.tsx b/src/components/server-catalog/CatalogResults.test.tsx
index 7a7c5a9f..a19ff3a6 100644
--- a/src/components/server-catalog/CatalogResults.test.tsx
+++ b/src/components/server-catalog/CatalogResults.test.tsx
@@ -26,6 +26,10 @@ function catalogResults(server: CatalogServer, addingServerIds: ReadonlySet
);
}
diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx
index 1dbe6956..8684cf41 100644
--- a/src/components/server-catalog/CatalogResults.tsx
+++ b/src/components/server-catalog/CatalogResults.tsx
@@ -1,6 +1,6 @@
import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
-import { CircleCheck, EllipsisVertical, FileText, Plus } from "lucide-react";
+import { CircleCheck, EllipsisVertical, FileText, PlugZap, Plus, Unplug } from "lucide-react";
import { useIntl } from "react-intl";
import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder";
@@ -25,6 +25,8 @@ import type { CatalogServer } from "@/generated/types";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { getTagLabels } from "@/utils/tags";
+const EMPTY_PENDING_IDS: ReadonlySet = new Set();
+
function getSafeExternalUrl(value: string | null | undefined): string | null {
if (!value) return null;
@@ -72,12 +74,24 @@ function CatalogCard({
server,
onView,
onAdd,
+ onTest,
+ onDisconnect,
isAdding,
+ isTesting,
+ isDisconnecting,
+ canTest,
+ canDisconnect,
}: {
server: CatalogServer;
onView: (trigger: HTMLElement) => void;
onAdd: () => void;
+ onTest: () => void;
+ onDisconnect: () => void;
isAdding: boolean;
+ isTesting: boolean;
+ isDisconnecting: boolean;
+ canTest: boolean;
+ canDisconnect: boolean;
}) {
const intl = useIntl();
const headingId = useId();
@@ -154,6 +168,29 @@ function CatalogCard({
>
{intl.formatMessage({ id: "mcpServer.catalog.viewDetails" })}
+ {canTest && (
+
+
+ {intl.formatMessage({ id: "mcpServer.catalog.test" })}
+
+ )}
+ {canDisconnect && server.gateway_id && (
+
+
+ {intl.formatMessage({ id: "mcpServer.catalog.disconnect" })}
+
+ )}
>
@@ -287,12 +324,24 @@ export function CatalogResults({
onView,
onAdd,
addingServerIds,
+ onTest,
+ onDisconnect,
+ testingServerIds = EMPTY_PENDING_IDS,
+ disconnectingServerIds = EMPTY_PENDING_IDS,
+ canTest,
+ canDisconnect,
}: {
servers: CatalogServer[];
emptyStateMessageId: string;
onView: (server: CatalogServer, trigger: HTMLElement) => void;
onAdd: (server: CatalogServer) => void;
addingServerIds: ReadonlySet;
+ onTest: (server: CatalogServer) => void;
+ onDisconnect: (server: CatalogServer) => void;
+ testingServerIds?: ReadonlySet;
+ disconnectingServerIds?: ReadonlySet;
+ canTest: boolean;
+ canDisconnect: boolean;
}) {
const intl = useIntl();
const announcedCount = useDebouncedValue(servers.length, 300);
@@ -313,7 +362,13 @@ export function CatalogResults({
server={server}
onView={(trigger) => onView(server, trigger)}
onAdd={() => onAdd(server)}
+ onTest={() => onTest(server)}
+ onDisconnect={() => onDisconnect(server)}
isAdding={addingServerIds.has(server.id)}
+ isTesting={testingServerIds.has(server.id)}
+ isDisconnecting={disconnectingServerIds.has(server.id)}
+ canTest={canTest}
+ canDisconnect={canDisconnect}
/>
))}
diff --git a/src/components/servers/ConfirmDialog.tsx b/src/components/servers/ConfirmDialog.tsx
index cc88388e..a3698bcd 100644
--- a/src/components/servers/ConfirmDialog.tsx
+++ b/src/components/servers/ConfirmDialog.tsx
@@ -1,4 +1,5 @@
import { useCallback } from "react";
+import type { ReactNode } from "react";
import {
Dialog,
DialogContent,
@@ -14,7 +15,7 @@ interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
- description: string;
+ description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
variant?: "default" | "destructive";
@@ -68,7 +69,9 @@ export function ConfirmDialog({
{title}
- {description}
+
+ {description}
+
{impactPreviewLoading && (
-
+
{intl.formatMessage({ id: "mcpServer.catalog.disconnectImpactLoading" })}
)}
From b5fb5fe840fa1dc662be639b5ffd38124b8aee77 Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Wed, 26 Aug 2026 13:56:01 +0100
Subject: [PATCH 4/8] test: cover catalog disconnect retry
Signed-off-by: Vishu Bhatnagar
---
src/pages/ServerCatalog.test.tsx | 45 +++++++++++++++++++++++++++++++-
1 file changed, 44 insertions(+), 1 deletion(-)
diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx
index 7c97d8cd..b98d59fa 100644
--- a/src/pages/ServerCatalog.test.tsx
+++ b/src/pages/ServerCatalog.test.tsx
@@ -1,6 +1,6 @@
import type { ReactElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
@@ -332,6 +332,49 @@ describe("ServerCatalog", () => {
expect(await screen.findByText("Globalping disconnected.")).toBeInTheDocument();
});
+ it("retries async disconnect status without sending DELETE again", async () => {
+ const user = userEvent.setup();
+ const refetch = vi.fn().mockResolvedValue(response);
+ mockUseQuery.mockReturnValue(queryResult({ refetch }));
+ mockDisconnectCatalogGateway.mockClear();
+ mockDisconnectCatalogGateway.mockResolvedValue({
+ status: 202,
+ data: { status: "deleting" },
+ headers: new Headers({ "Retry-After": "0.001" }),
+ });
+
+ renderWithRouter();
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
+ const dialog = await screen.findByRole("alertdialog");
+
+ vi.useFakeTimers();
+ try {
+ fireEvent.click(within(dialog).getByRole("button", { name: "Disconnect" }));
+
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(60_000);
+ });
+
+ expect(screen.getByText("Globalping is still disconnecting. Refresh shortly.")).toBeVisible();
+ refetch.mockResolvedValue({
+ ...response,
+ servers: [{ ...openConnected, is_registered: false, gateway_id: null }],
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+
+ expect(screen.getByText("Globalping disconnected.")).toBeVisible();
+ expect(mockDisconnectCatalogGateway).toHaveBeenCalledOnce();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("keeps catalog state unchanged when backend rejects disconnect ownership", async () => {
const user = userEvent.setup();
const refetch = vi.fn();
From 5d194b00daac0deda603895859fd7589c481c7af Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Wed, 26 Aug 2026 14:33:41 +0100
Subject: [PATCH 5/8] test: cover catalog disconnect polling
Signed-off-by: Vishu Bhatnagar
---
src/pages/ServerCatalog.test.tsx | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx
index b98d59fa..fb3f81e2 100644
--- a/src/pages/ServerCatalog.test.tsx
+++ b/src/pages/ServerCatalog.test.tsx
@@ -334,7 +334,10 @@ describe("ServerCatalog", () => {
it("retries async disconnect status without sending DELETE again", async () => {
const user = userEvent.setup();
- const refetch = vi.fn().mockResolvedValue(response);
+ const refetch = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("Temporary catalog failure"))
+ .mockResolvedValue(response);
mockUseQuery.mockReturnValue(queryResult({ refetch }));
mockDisconnectCatalogGateway.mockClear();
mockDisconnectCatalogGateway.mockResolvedValue({
@@ -357,6 +360,13 @@ describe("ServerCatalog", () => {
await vi.advanceTimersByTimeAsync(60_000);
});
+ expect(screen.getByText("Globalping is still disconnecting. Refresh shortly.")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_000);
+ });
+
expect(screen.getByText("Globalping is still disconnecting. Refresh shortly.")).toBeVisible();
refetch.mockResolvedValue({
...response,
From 3b9d25eb90d6d601fb896c0a8d7ce20277c30e85 Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Thu, 27 Aug 2026 09:36:31 +0100
Subject: [PATCH 6/8] fix: harden catalog disconnect flow
Signed-off-by: Vishu Bhatnagar
---
src/i18n/locales/en-US/mcpServer.json | 4 +-
src/i18n/locales/es-ES/mcpServer.json | 4 +-
src/i18n/locales/pt-BR/mcpServer.json | 4 +-
src/pages/ServerCatalog.test.tsx | 80 +++++-
src/pages/ServerCatalog.tsx | 338 ++++++++++++++++----------
5 files changed, 297 insertions(+), 133 deletions(-)
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index 2dd50276..c7976ac5 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -101,8 +101,8 @@
"mcpServer.catalog.viewDetails": "View details",
"mcpServer.catalog.test": "Test connection",
"mcpServer.catalog.testOAuthPending": "Complete OAuth configuration before testing this server.",
- "mcpServer.catalog.testSuccess": "{name} responded with status {statusCode} in {latencyMs} ms.",
- "mcpServer.catalog.testFailure": "{name} responded with status {statusCode} in {latencyMs} ms.",
+ "mcpServer.catalog.testSuccess": "{name} connection succeeded with status {statusCode} in {latencyMs} ms.",
+ "mcpServer.catalog.testFailure": "{name} connection failed with status {statusCode} in {latencyMs} ms.",
"mcpServer.catalog.testError": "Unable to test {name}. Try again.",
"mcpServer.catalog.disconnect": "Disconnect",
"mcpServer.catalog.disconnecting": "Disconnecting…",
diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json
index e27490e8..1bb8e1e8 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -101,8 +101,8 @@
"mcpServer.catalog.viewDetails": "Ver detalles",
"mcpServer.catalog.test": "Probar conexión",
"mcpServer.catalog.testOAuthPending": "Completa la configuración de OAuth antes de probar este servidor.",
- "mcpServer.catalog.testSuccess": "{name} respondió con el estado {statusCode} en {latencyMs} ms.",
- "mcpServer.catalog.testFailure": "{name} respondió con el estado {statusCode} en {latencyMs} ms.",
+ "mcpServer.catalog.testSuccess": "La conexión con {name} se realizó correctamente con el estado {statusCode} en {latencyMs} ms.",
+ "mcpServer.catalog.testFailure": "La conexión con {name} falló con el estado {statusCode} en {latencyMs} ms.",
"mcpServer.catalog.testError": "No se pudo probar {name}. Inténtalo de nuevo.",
"mcpServer.catalog.disconnect": "Desconectar",
"mcpServer.catalog.disconnecting": "Desconectando…",
diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json
index 85071ebb..04a9ade7 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -101,8 +101,8 @@
"mcpServer.catalog.viewDetails": "Ver detalhes",
"mcpServer.catalog.test": "Testar conexão",
"mcpServer.catalog.testOAuthPending": "Conclua a configuração do OAuth antes de testar este servidor.",
- "mcpServer.catalog.testSuccess": "{name} respondeu com status {statusCode} em {latencyMs} ms.",
- "mcpServer.catalog.testFailure": "{name} respondeu com status {statusCode} em {latencyMs} ms.",
+ "mcpServer.catalog.testSuccess": "A conexão com {name} foi bem-sucedida com status {statusCode} em {latencyMs} ms.",
+ "mcpServer.catalog.testFailure": "A conexão com {name} falhou com status {statusCode} em {latencyMs} ms.",
"mcpServer.catalog.testError": "Não foi possível testar {name}. Tente novamente.",
"mcpServer.catalog.disconnect": "Desconectar",
"mcpServer.catalog.disconnecting": "Desconectando…",
diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx
index fb3f81e2..2732f44f 100644
--- a/src/pages/ServerCatalog.test.tsx
+++ b/src/pages/ServerCatalog.test.tsx
@@ -20,7 +20,7 @@ vi.mock("@/hooks/useQuery", () => ({
useQuery: vi.fn(),
}));
const authState = vi.hoisted(() => ({
- hasPermission: vi.fn(() => true),
+ hasPermission: vi.fn<(permission: string) => boolean>(() => true),
permissionsLoading: false,
}));
vi.mock("@/auth/useAuth", () => ({
@@ -225,7 +225,39 @@ describe("ServerCatalog", () => {
await waitFor(() =>
expect(mockTestCatalogServer).toHaveBeenCalledWith("https://globalping.example/mcp"),
);
- expect(await screen.findByText("Globalping responded with status 200 in 12 ms.")).toBeVisible();
+ expect(
+ await screen.findByText("Globalping connection succeeded with status 200 in 12 ms."),
+ ).toBeVisible();
+ });
+
+ it("reports a failed connection distinctly from a successful connection", async () => {
+ const user = userEvent.setup();
+ mockTestCatalogServer.mockResolvedValue({ statusCode: 503, latencyMs: 12 });
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Test connection" }));
+
+ expect(
+ await screen.findByText("Globalping connection failed with status 503 in 12 ms."),
+ ).toBeVisible();
+ });
+
+ it("keeps notifications from independent server mutations", async () => {
+ const user = userEvent.setup();
+ mockTestCatalogServer.mockResolvedValue({ statusCode: 503, latencyMs: 12 });
+ mockRegisterCatalogServer.mockRejectedValue(new Error("Network error"));
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Test connection" }));
+ await screen.findByText("Globalping connection failed with status 503 in 12 ms.");
+
+ await user.click(screen.getByRole("button", { name: "Add Public Notes" }));
+ expect(await screen.findByText("Unable to add this server. Try again.")).toBeVisible();
+ expect(
+ screen.getByText("Globalping connection failed with status 503 in 12 ms."),
+ ).toBeVisible();
});
it("disables Test when OAuth configuration remains incomplete", async () => {
@@ -261,6 +293,20 @@ describe("ServerCatalog", () => {
expect(screen.getByRole("menuitem", { name: "View details" })).toBeInTheDocument();
});
+ it("loads disconnect impact for a caller allowed to delete but not test", async () => {
+ const user = userEvent.setup();
+ authState.hasPermission.mockImplementation((permission) => permission === "gateways.delete");
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ expect(screen.queryByRole("menuitem", { name: "Test connection" })).not.toBeInTheDocument();
+ await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
+
+ await waitFor(() =>
+ expect(mockGetGatewayImpactPreview).toHaveBeenCalledWith("gateway-globalping"),
+ );
+ });
+
it("confirms disconnect, shows affected virtual servers, then refetches catalog", async () => {
const user = userEvent.setup();
const refetch = vi.fn().mockResolvedValue(response);
@@ -385,6 +431,36 @@ describe("ServerCatalog", () => {
}
});
+ it("reports persistent catalog polling failures as errors", async () => {
+ const user = userEvent.setup();
+ const refetch = vi.fn().mockRejectedValue(new ApiError(500, null, "HTTP 500"));
+ mockUseQuery.mockReturnValue(queryResult({ refetch }));
+ mockDisconnectCatalogGateway.mockResolvedValue({
+ status: 202,
+ data: { status: "deleting" },
+ headers: new Headers({ "Retry-After": "0.001" }),
+ });
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
+ const dialog = await screen.findByRole("alertdialog");
+
+ vi.useFakeTimers();
+ try {
+ fireEvent.click(within(dialog).getByRole("button", { name: "Disconnect" }));
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(60_000);
+ });
+
+ expect(screen.getByText("Unable to disconnect Globalping. Try again.")).toBeVisible();
+ expect(refetch).toHaveBeenCalledTimes(3);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("keeps catalog state unchanged when backend rejects disconnect ownership", async () => {
const user = userEvent.setup();
const refetch = vi.fn();
diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx
index cb8ca6c0..a05cfed5 100644
--- a/src/pages/ServerCatalog.tsx
+++ b/src/pages/ServerCatalog.tsx
@@ -45,6 +45,7 @@ interface CatalogFilters {
}
interface RegistrationNotification {
+ id: string;
type: "success" | "error";
message: string;
retryCatalogServer?: CatalogServer;
@@ -306,17 +307,18 @@ export function ServerCatalog() {
end: endDisconnecting,
isPending: isDisconnecting,
} = usePendingIds();
- const [registrationNotification, setRegistrationNotification] =
- useState(null);
+ const [registrationNotifications, setRegistrationNotifications] = useState<
+ RegistrationNotification[]
+ >([]);
const [disconnectServer, setDisconnectServer] = useState(null);
const [impactPreview, setImpactPreview] = useState(null);
const [impactPreviewLoading, setImpactPreviewLoading] = useState(false);
const impactRequestIdRef = useRef(0);
- const disconnectPollAbortRef = useRef(null);
+ const disconnectPollAbortControllersRef = useRef(new Map());
const lastViewTriggerRef = useRef(null);
const pageHeadingRef = useRef(null);
- const registrationNotificationRef = useRef(null);
- const shouldFocusRegistrationNotificationRef = useRef(false);
+ const registrationNotificationRefs = useRef(new Map());
+ const notificationToFocusRef = useRef(null);
const { data, error, isLoading, refetch, setData } = useQuery(CATALOG_PATH);
const canTest = !permissionsLoading && hasPermission("gateways.read");
const canDisconnect = !permissionsLoading && hasPermission("gateways.delete");
@@ -336,20 +338,39 @@ export function ServerCatalog() {
}, [debouncedSearch, filters.search, updateQuery]);
useEffect(() => {
- if (!registrationNotification || !shouldFocusRegistrationNotificationRef.current) return;
+ const notificationId = notificationToFocusRef.current;
+ if (!notificationId) return;
- shouldFocusRegistrationNotificationRef.current = false;
- registrationNotificationRef.current?.focus();
- }, [registrationNotification]);
+ notificationToFocusRef.current = null;
+ registrationNotificationRefs.current.get(notificationId)?.focus();
+ }, [registrationNotifications]);
useEffect(
() => () => {
impactRequestIdRef.current += 1;
- disconnectPollAbortRef.current?.abort();
+ disconnectPollAbortControllersRef.current.forEach((controller) => controller.abort());
+ disconnectPollAbortControllersRef.current.clear();
},
[],
);
+ const showRegistrationNotification = useCallback(
+ (notification: RegistrationNotification, shouldFocus = false) => {
+ if (shouldFocus) notificationToFocusRef.current = notification.id;
+ setRegistrationNotifications((current) => [
+ ...current.filter((existing) => existing.id !== notification.id),
+ notification,
+ ]);
+ },
+ [],
+ );
+
+ const dismissRegistrationNotification = useCallback((notificationId: string) => {
+ setRegistrationNotifications((current) =>
+ current.filter((notification) => notification.id !== notificationId),
+ );
+ }, []);
+
// Only the search box filters ahead of the URL, so the grid can react to the
// debounced value. Category, provider and tag selections are committed to the
// URL the moment they are ticked in the filters popover.
@@ -398,11 +419,12 @@ export function ServerCatalog() {
const handleAdd = useCallback(
async (server: CatalogServer) => {
if (!beginAdding(server.id)) return;
- setRegistrationNotification(null);
+ dismissRegistrationNotification(`add:${server.id}`);
try {
const result = await registerCatalogServer(server.id);
if (!result.success) {
- setRegistrationNotification({
+ showRegistrationNotification({
+ id: `add:${server.id}`,
type: "error",
message: result.message || intl.formatMessage({ id: "mcpServer.catalog.addError" }),
});
@@ -415,7 +437,8 @@ export function ServerCatalog() {
void refreshCatalogSilently();
} catch (registrationError) {
if (registrationError instanceof ApiError && registrationError.status === 409) {
- setRegistrationNotification({
+ showRegistrationNotification({
+ id: `add:${server.id}`,
type: "success",
message: intl.formatMessage(
{ id: "mcpServer.catalog.alreadyConnected" },
@@ -428,19 +451,23 @@ export function ServerCatalog() {
if (registrationError instanceof ApiError && registrationError.status === 404) {
setData((current) => removeCatalogServer(current, server.id));
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: "error",
- message: intl.formatMessage(
- { id: "mcpServer.catalog.addNotFound" },
- { name: server.name },
- ),
- });
+ showRegistrationNotification(
+ {
+ id: `add:${server.id}`,
+ type: "error",
+ message: intl.formatMessage(
+ { id: "mcpServer.catalog.addNotFound" },
+ { name: server.name },
+ ),
+ },
+ true,
+ );
await refreshCatalogSilently();
return;
}
- setRegistrationNotification({
+ showRegistrationNotification({
+ id: `add:${server.id}`,
type: "error",
message: intl.formatMessage({ id: "mcpServer.catalog.addError" }),
});
@@ -448,7 +475,15 @@ export function ServerCatalog() {
endAdding(server.id);
}
},
- [beginAdding, endAdding, intl, refreshCatalogSilently, setData],
+ [
+ beginAdding,
+ dismissRegistrationNotification,
+ endAdding,
+ intl,
+ refreshCatalogSilently,
+ setData,
+ showRegistrationNotification,
+ ],
);
const handleTest = useCallback(
@@ -457,33 +492,49 @@ export function ServerCatalog() {
return;
}
- setRegistrationNotification(null);
+ dismissRegistrationNotification(`test:${server.id}`);
try {
const result = await testCatalogServer(server.url);
const statusCode = result?.statusCode ?? 0;
const succeeded = statusCode >= 200 && statusCode < 300;
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: succeeded ? "success" : "error",
- message: intl.formatMessage(
- {
- id: succeeded ? "mcpServer.catalog.testSuccess" : "mcpServer.catalog.testFailure",
- },
- { name: server.name, statusCode, latencyMs: result?.latencyMs ?? 0 },
- ),
- });
+ showRegistrationNotification(
+ {
+ id: `test:${server.id}`,
+ type: succeeded ? "success" : "error",
+ message: intl.formatMessage(
+ {
+ id: succeeded ? "mcpServer.catalog.testSuccess" : "mcpServer.catalog.testFailure",
+ },
+ { name: server.name, statusCode, latencyMs: result?.latencyMs ?? 0 },
+ ),
+ },
+ true,
+ );
} catch {
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: "error",
- message: intl.formatMessage({ id: "mcpServer.catalog.testError" }, { name: server.name }),
- });
+ showRegistrationNotification(
+ {
+ id: `test:${server.id}`,
+ type: "error",
+ message: intl.formatMessage(
+ { id: "mcpServer.catalog.testError" },
+ { name: server.name },
+ ),
+ },
+ true,
+ );
} finally {
endTesting(server.id);
}
},
- [beginTesting, endTesting, intl, isDisconnecting],
+ [
+ beginTesting,
+ dismissRegistrationNotification,
+ endTesting,
+ intl,
+ isDisconnecting,
+ showRegistrationNotification,
+ ],
);
const handleDisconnect = useCallback(
@@ -493,7 +544,7 @@ export function ServerCatalog() {
setDisconnectServer(server);
setImpactPreview(null);
const requestId = ++impactRequestIdRef.current;
- if (!canTest) return;
+ if (!canDisconnect) return;
setImpactPreviewLoading(true);
void getGatewayImpactPreview(server.gateway_id)
@@ -509,7 +560,7 @@ export function ServerCatalog() {
if (impactRequestIdRef.current === requestId) setImpactPreviewLoading(false);
});
},
- [canTest, isDisconnecting, isTesting],
+ [canDisconnect, isDisconnecting, isTesting],
);
const handleDisconnectDialogOpenChange = useCallback((open: boolean) => {
@@ -524,6 +575,7 @@ export function ServerCatalog() {
async (catalogId: string, initialDelayMs: number, signal: AbortSignal) => {
const deadline = Date.now() + DISCONNECT_POLL_TIMEOUT_MS;
let delayMs = initialDelayMs;
+ let consecutivePollErrors = 0;
while (Date.now() < deadline) {
await delay(delayMs, signal);
@@ -531,9 +583,18 @@ export function ServerCatalog() {
const catalog = await refetch();
const server = catalog.servers.find((candidate) => candidate.id === catalogId);
if (!server?.is_registered || !server.gateway_id) return;
- } catch {
+ consecutivePollErrors = 0;
+ } catch (error) {
+ if (isAbortError(error)) throw error;
+ consecutivePollErrors += 1;
+ if (
+ (error instanceof ApiError && error.status >= 400 && error.status < 500) ||
+ consecutivePollErrors >= 3
+ ) {
+ throw error;
+ }
// Deletion has already been accepted. Keep polling through transient
- // catalog failures instead of reporting it as a failed deletion.
+ // catalog failures, but surface persistent or authorization failures.
}
delayMs = DEFAULT_DISCONNECT_POLL_MS;
}
@@ -548,48 +609,60 @@ export function ServerCatalog() {
if (!beginDisconnecting(server.id)) return;
let pollController: AbortController | null = null;
- setRegistrationNotification(null);
+ dismissRegistrationNotification(`disconnect:${server.id}`);
try {
- disconnectPollAbortRef.current?.abort();
pollController = new AbortController();
- disconnectPollAbortRef.current = pollController;
+ disconnectPollAbortControllersRef.current.set(server.id, pollController);
await waitForCatalogDisconnect(
server.id,
DEFAULT_DISCONNECT_POLL_MS,
pollController.signal,
);
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: "success",
- message: intl.formatMessage(
- { id: "mcpServer.catalog.disconnectSuccess" },
- { name: server.name },
- ),
- });
+ showRegistrationNotification(
+ {
+ id: `disconnect:${server.id}`,
+ type: "success",
+ message: intl.formatMessage(
+ { id: "mcpServer.catalog.disconnectSuccess" },
+ { name: server.name },
+ ),
+ },
+ true,
+ );
} catch (error) {
if (isAbortError(error)) return;
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: "error",
- message: intl.formatMessage(
- {
- id:
- error instanceof DisconnectPollTimeoutError
- ? "mcpServer.catalog.disconnectPending"
- : "mcpServer.catalog.disconnectError",
- },
- { name: server.name },
- ),
- ...(error instanceof DisconnectPollTimeoutError ? { retryCatalogServer: server } : {}),
- });
+ showRegistrationNotification(
+ {
+ id: `disconnect:${server.id}`,
+ type: "error",
+ message: intl.formatMessage(
+ {
+ id:
+ error instanceof DisconnectPollTimeoutError
+ ? "mcpServer.catalog.disconnectPending"
+ : "mcpServer.catalog.disconnectError",
+ },
+ { name: server.name },
+ ),
+ ...(error instanceof DisconnectPollTimeoutError ? { retryCatalogServer: server } : {}),
+ },
+ true,
+ );
} finally {
- if (disconnectPollAbortRef.current === pollController) {
- disconnectPollAbortRef.current = null;
+ if (disconnectPollAbortControllersRef.current.get(server.id) === pollController) {
+ disconnectPollAbortControllersRef.current.delete(server.id);
}
endDisconnecting(server.id);
}
},
- [beginDisconnecting, endDisconnecting, intl, waitForCatalogDisconnect],
+ [
+ beginDisconnecting,
+ dismissRegistrationNotification,
+ endDisconnecting,
+ intl,
+ showRegistrationNotification,
+ waitForCatalogDisconnect,
+ ],
);
const confirmDisconnect = useCallback(async () => {
@@ -604,9 +677,8 @@ export function ServerCatalog() {
try {
const response = await disconnectCatalogGateway(gatewayId);
if (response.status === 202) {
- disconnectPollAbortRef.current?.abort();
pollController = new AbortController();
- disconnectPollAbortRef.current = pollController;
+ disconnectPollAbortControllersRef.current.set(server.id, pollController);
await waitForCatalogDisconnect(
server.id,
getRetryAfterMs(response.headers.get("Retry-After")),
@@ -619,37 +691,43 @@ export function ServerCatalog() {
impactRequestIdRef.current += 1;
setDisconnectServer(null);
setImpactPreview(null);
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: "success",
- message: intl.formatMessage(
- { id: "mcpServer.catalog.disconnectSuccess" },
- { name: server.name },
- ),
- });
+ showRegistrationNotification(
+ {
+ id: `disconnect:${server.id}`,
+ type: "success",
+ message: intl.formatMessage(
+ { id: "mcpServer.catalog.disconnectSuccess" },
+ { name: server.name },
+ ),
+ },
+ true,
+ );
} catch (error) {
if (isAbortError(error)) return;
if (error instanceof DisconnectPollTimeoutError) {
setDisconnectServer(null);
setImpactPreview(null);
}
- shouldFocusRegistrationNotificationRef.current = true;
- setRegistrationNotification({
- type: "error",
- message: intl.formatMessage(
- {
- id:
- error instanceof DisconnectPollTimeoutError
- ? "mcpServer.catalog.disconnectPending"
- : "mcpServer.catalog.disconnectError",
- },
- { name: server.name },
- ),
- ...(error instanceof DisconnectPollTimeoutError ? { retryCatalogServer: server } : {}),
- });
+ showRegistrationNotification(
+ {
+ id: `disconnect:${server.id}`,
+ type: "error",
+ message: intl.formatMessage(
+ {
+ id:
+ error instanceof DisconnectPollTimeoutError
+ ? "mcpServer.catalog.disconnectPending"
+ : "mcpServer.catalog.disconnectError",
+ },
+ { name: server.name },
+ ),
+ ...(error instanceof DisconnectPollTimeoutError ? { retryCatalogServer: server } : {}),
+ },
+ true,
+ );
} finally {
- if (disconnectPollAbortRef.current === pollController) {
- disconnectPollAbortRef.current = null;
+ if (disconnectPollAbortControllersRef.current.get(server.id) === pollController) {
+ disconnectPollAbortControllersRef.current.delete(server.id);
}
endDisconnecting(server.id);
}
@@ -660,6 +738,7 @@ export function ServerCatalog() {
intl,
refreshCatalogSilently,
setData,
+ showRegistrationNotification,
waitForCatalogDisconnect,
]);
@@ -669,14 +748,17 @@ export function ServerCatalog() {
window.setTimeout(() => lastViewTriggerRef.current?.focus(), 0);
}, []);
- const handleRegistrationNotificationDismiss = useCallback(() => {
- const notification = registrationNotificationRef.current;
- const shouldRestoreFocus = notification?.contains(notification.ownerDocument.activeElement);
- setRegistrationNotification(null);
- if (shouldRestoreFocus) {
- window.setTimeout(() => pageHeadingRef.current?.focus(), 0);
- }
- }, []);
+ const handleRegistrationNotificationDismiss = useCallback(
+ (notificationId: string) => {
+ const notification = registrationNotificationRefs.current.get(notificationId);
+ const shouldRestoreFocus = notification?.contains(notification.ownerDocument.activeElement);
+ dismissRegistrationNotification(notificationId);
+ if (shouldRestoreFocus) {
+ window.setTimeout(() => pageHeadingRef.current?.focus(), 0);
+ }
+ },
+ [dismissRegistrationNotification],
+ );
if (isLoading && !data) {
return (
@@ -738,26 +820,32 @@ export function ServerCatalog() {
onClearAll={clearAllFilters}
/>
- {registrationNotification && (
+ {registrationNotifications.length > 0 && (
-
- void handleDisconnectStatusRetry(
- registrationNotification.retryCatalogServer!,
- ),
- }
- : undefined
- }
- tabIndex={-1}
- onDismiss={handleRegistrationNotificationDismiss}
- />
+
+ {registrationNotifications.map((notification) => (
+ {
+ if (element) registrationNotificationRefs.current.set(notification.id, element);
+ else registrationNotificationRefs.current.delete(notification.id);
+ }}
+ type={notification.type}
+ message={notification.message}
+ action={
+ notification.retryCatalogServer
+ ? {
+ label: intl.formatMessage({ id: "mcpServer.catalog.retry" }),
+ onClick: () =>
+ void handleDisconnectStatusRetry(notification.retryCatalogServer!),
+ }
+ : undefined
+ }
+ tabIndex={-1}
+ onDismiss={() => handleRegistrationNotificationDismiss(notification.id)}
+ />
+ ))}
+
)}
From a42271eb0987ec620a31448df9ac17ca3841e960 Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Thu, 27 Aug 2026 14:29:05 +0100
Subject: [PATCH 7/8] fix: address catalog action review feedback
Signed-off-by: Vishu Bhatnagar
---
.../server-catalog/CatalogResults.test.tsx | 43 ++++++-
.../server-catalog/CatalogResults.tsx | 36 ++++--
.../ui/inline-notification.test.tsx | 9 ++
src/components/ui/inline-notification.tsx | 19 ++-
src/i18n/locales/en-US/mcpServer.json | 6 +-
src/i18n/locales/es-ES/mcpServer.json | 6 +-
src/i18n/locales/pt-BR/mcpServer.json | 6 +-
src/pages/ServerCatalog.test.tsx | 75 +++++++++--
src/pages/ServerCatalog.tsx | 117 ++++++++++++++----
9 files changed, 260 insertions(+), 57 deletions(-)
diff --git a/src/components/server-catalog/CatalogResults.test.tsx b/src/components/server-catalog/CatalogResults.test.tsx
index a19ff3a6..5adeb0ca 100644
--- a/src/components/server-catalog/CatalogResults.test.tsx
+++ b/src/components/server-catalog/CatalogResults.test.tsx
@@ -18,7 +18,12 @@ const availableServer: CatalogServer = {
is_registered: false,
};
-function catalogResults(server: CatalogServer, addingServerIds: ReadonlySet = new Set()) {
+function catalogResults(
+ server: CatalogServer,
+ addingServerIds: ReadonlySet = new Set(),
+ testingServerIds: ReadonlySet = new Set(),
+ disconnectingServerIds: ReadonlySet = new Set(),
+) {
return (
@@ -68,4 +75,38 @@ describe("CatalogResults", () => {
expect(await screen.findByRole("menu")).toHaveAttribute("data-align", "end");
});
+
+ it("shows testing and disconnecting status on the affected card", () => {
+ const connectedServer = { ...availableServer, is_registered: true, gateway_id: "gateway-1" };
+ const { rerender } = renderWithProviders(
+ catalogResults(connectedServer, new Set(), new Set([connectedServer.id])),
+ );
+
+ expect(screen.getByText("Testing connection…")).toHaveAttribute("role", "status");
+
+ rerender(catalogResults(connectedServer, new Set(), new Set(), new Set([connectedServer.id])));
+
+ expect(screen.getByText("Disconnecting…")).toHaveAttribute("role", "status");
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
+ });
+
+ it("routes bundled catalog logos through the BFF", () => {
+ const { container } = renderWithProviders(
+ catalogResults({ ...availableServer, logo_url: "/static/catalog-icons/asana.png" }),
+ );
+
+ const logo = container.querySelector("img");
+
+ expect(logo).toHaveAttribute("src", "/api/static/catalog-icons/asana.png");
+ expect(logo).toHaveClass("size-full", "object-contain");
+ expect(logo?.parentElement).not.toHaveClass("bg-muted");
+ });
+
+ it("rejects local logo paths outside the catalog icon directory", () => {
+ const { container } = renderWithProviders(
+ catalogResults({ ...availableServer, logo_url: "/static/admin.png" }),
+ );
+
+ expect(container.querySelector("img")).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx
index 8684cf41..1178c74d 100644
--- a/src/components/server-catalog/CatalogResults.tsx
+++ b/src/components/server-catalog/CatalogResults.tsx
@@ -1,6 +1,6 @@
import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
-import { CircleCheck, EllipsisVertical, FileText, PlugZap, Plus, Unplug } from "lucide-react";
+import { CircleCheck, EllipsisVertical, FileText, Plus } from "lucide-react";
import { useIntl } from "react-intl";
import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder";
@@ -26,10 +26,17 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { getTagLabels } from "@/utils/tags";
const EMPTY_PENDING_IDS: ReadonlySet = new Set();
+const CATALOG_ICON_PATH = /^\/static\/catalog-icons\/[A-Za-z0-9][A-Za-z0-9._-]*\.png$/;
function getSafeExternalUrl(value: string | null | undefined): string | null {
if (!value) return null;
+ // Catalog icons are packaged by the API under this fixed path. Route them
+ // through the authenticated BFF so the browser never needs an API origin.
+ if (CATALOG_ICON_PATH.test(value)) {
+ return `/api${value}`;
+ }
+
try {
const parsed = new URL(value);
return parsed.protocol === "https:" && !parsed.username && !parsed.password
@@ -53,14 +60,11 @@ function CatalogLogo({ server }: { server: CatalogServer }) {
}
return (
-
+

{server.is_registered ? (
<>
-
-
- {intl.formatMessage({ id: "mcpServer.catalog.connected" })}
-
+ {isDisconnecting ? (
+
+ {intl.formatMessage({ id: "mcpServer.catalog.disconnecting" })}
+
+ ) : isTesting ? (
+
+ {intl.formatMessage({ id: "mcpServer.catalog.testing" })}
+
+ ) : (
+
+
+ {intl.formatMessage({ id: "mcpServer.catalog.connected" })}
+
+ )}
-
{intl.formatMessage({ id: "mcpServer.catalog.test" })}
)}
@@ -187,7 +200,6 @@ function CatalogCard({
disabled={isTesting || isDisconnecting}
onSelect={onDisconnect}
>
-
{intl.formatMessage({ id: "mcpServer.catalog.disconnect" })}
)}
diff --git a/src/components/ui/inline-notification.test.tsx b/src/components/ui/inline-notification.test.tsx
index aefc7c96..3763ea6c 100644
--- a/src/components/ui/inline-notification.test.tsx
+++ b/src/components/ui/inline-notification.test.tsx
@@ -49,6 +49,15 @@ describe("InlineNotification", () => {
});
});
+ describe("info type", () => {
+ it("renders with role='status' and neutral text", () => {
+ render();
+
+ expect(screen.getByRole("status")).toHaveTextContent("Disconnect pending");
+ expect(screen.getByText("Disconnect pending")).toHaveClass("text-blue-600");
+ });
+ });
+
describe("dismiss button", () => {
it("renders dismiss button when onDismiss is provided", () => {
render();
diff --git a/src/components/ui/inline-notification.tsx b/src/components/ui/inline-notification.tsx
index 0c28a5a5..797df808 100644
--- a/src/components/ui/inline-notification.tsx
+++ b/src/components/ui/inline-notification.tsx
@@ -1,9 +1,9 @@
import { forwardRef } from "react";
-import { CircleCheck, CircleAlert, X } from "lucide-react";
+import { CircleAlert, CircleCheck, Info, X } from "lucide-react";
import { Button } from "@/components/ui/button";
interface InlineNotificationProps {
- type: "success" | "error";
+ type: "success" | "error" | "info";
message: string;
action?: {
label: string;
@@ -20,22 +20,31 @@ export const InlineNotification = forwardRef
{isSuccess ? (
- ) : (
+ ) : isError ? (
+ ) : (
+
)}
{message}
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index c7976ac5..0f979a11 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -100,19 +100,23 @@
"mcpServer.catalog.actionsFor": "Actions for {name}",
"mcpServer.catalog.viewDetails": "View details",
"mcpServer.catalog.test": "Test connection",
+ "mcpServer.catalog.testing": "Testing connection…",
"mcpServer.catalog.testOAuthPending": "Complete OAuth configuration before testing this server.",
"mcpServer.catalog.testSuccess": "{name} connection succeeded with status {statusCode} in {latencyMs} ms.",
"mcpServer.catalog.testFailure": "{name} connection failed with status {statusCode} in {latencyMs} ms.",
"mcpServer.catalog.testError": "Unable to test {name}. Try again.",
"mcpServer.catalog.disconnect": "Disconnect",
"mcpServer.catalog.disconnecting": "Disconnecting…",
+ "mcpServer.catalog.disconnectAccepted": "{name} is disconnecting.",
"mcpServer.catalog.disconnectTitle": "Disconnect MCP server",
"mcpServer.catalog.disconnectDescription": "Disconnect {name}? This removes its gateway and cannot be undone.",
"mcpServer.catalog.disconnectImpactLoading": "Checking affected virtual servers…",
"mcpServer.catalog.disconnectImpact": "Affected virtual servers",
+ "mcpServer.catalog.disconnectImpactNone": "No affected virtual servers found.",
+ "mcpServer.catalog.disconnectImpactError": "Could not check affected virtual servers. You can still disconnect this server.",
"mcpServer.catalog.disconnectSuccess": "{name} disconnected.",
"mcpServer.catalog.disconnectError": "Unable to disconnect {name}. Try again.",
- "mcpServer.catalog.disconnectPending": "{name} is still disconnecting. Refresh shortly.",
+ "mcpServer.catalog.disconnectPending": "{name} is still disconnecting. You can retry shortly.",
"mcpServer.catalog.viewOptions": "Catalog view",
"mcpServer.catalog.transport": "Transport",
"mcpServer.catalog.status": "Status",
diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json
index 1bb8e1e8..ea2a3301 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -100,19 +100,23 @@
"mcpServer.catalog.actionsFor": "Acciones para {name}",
"mcpServer.catalog.viewDetails": "Ver detalles",
"mcpServer.catalog.test": "Probar conexión",
+ "mcpServer.catalog.testing": "Probando conexión…",
"mcpServer.catalog.testOAuthPending": "Completa la configuración de OAuth antes de probar este servidor.",
"mcpServer.catalog.testSuccess": "La conexión con {name} se realizó correctamente con el estado {statusCode} en {latencyMs} ms.",
"mcpServer.catalog.testFailure": "La conexión con {name} falló con el estado {statusCode} en {latencyMs} ms.",
"mcpServer.catalog.testError": "No se pudo probar {name}. Inténtalo de nuevo.",
"mcpServer.catalog.disconnect": "Desconectar",
"mcpServer.catalog.disconnecting": "Desconectando…",
+ "mcpServer.catalog.disconnectAccepted": "{name} se está desconectando.",
"mcpServer.catalog.disconnectTitle": "Desconectar servidor MCP",
"mcpServer.catalog.disconnectDescription": "¿Desconectar {name}? Esto elimina su puerta de enlace y no se puede deshacer.",
"mcpServer.catalog.disconnectImpactLoading": "Comprobando servidores virtuales afectados…",
"mcpServer.catalog.disconnectImpact": "Servidores virtuales afectados",
+ "mcpServer.catalog.disconnectImpactNone": "No se encontraron servidores virtuales afectados.",
+ "mcpServer.catalog.disconnectImpactError": "No se pudieron comprobar los servidores virtuales afectados. Aún puedes desconectar este servidor.",
"mcpServer.catalog.disconnectSuccess": "{name} se desconectó.",
"mcpServer.catalog.disconnectError": "No se pudo desconectar {name}. Inténtalo de nuevo.",
- "mcpServer.catalog.disconnectPending": "{name} todavía se está desconectando. Actualiza en breve.",
+ "mcpServer.catalog.disconnectPending": "{name} todavía se está desconectando. Puedes reintentar en breve.",
"mcpServer.catalog.viewOptions": "Vista del catálogo",
"mcpServer.catalog.transport": "Transporte",
"mcpServer.catalog.status": "Estado",
diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json
index 04a9ade7..5d8ad312 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -100,19 +100,23 @@
"mcpServer.catalog.actionsFor": "Ações para {name}",
"mcpServer.catalog.viewDetails": "Ver detalhes",
"mcpServer.catalog.test": "Testar conexão",
+ "mcpServer.catalog.testing": "Testando conexão…",
"mcpServer.catalog.testOAuthPending": "Conclua a configuração do OAuth antes de testar este servidor.",
"mcpServer.catalog.testSuccess": "A conexão com {name} foi bem-sucedida com status {statusCode} em {latencyMs} ms.",
"mcpServer.catalog.testFailure": "A conexão com {name} falhou com status {statusCode} em {latencyMs} ms.",
"mcpServer.catalog.testError": "Não foi possível testar {name}. Tente novamente.",
"mcpServer.catalog.disconnect": "Desconectar",
"mcpServer.catalog.disconnecting": "Desconectando…",
+ "mcpServer.catalog.disconnectAccepted": "{name} está desconectando.",
"mcpServer.catalog.disconnectTitle": "Desconectar servidor MCP",
"mcpServer.catalog.disconnectDescription": "Desconectar {name}? Isso remove o gateway e não pode ser desfeito.",
"mcpServer.catalog.disconnectImpactLoading": "Verificando servidores virtuais afetados…",
"mcpServer.catalog.disconnectImpact": "Servidores virtuais afetados",
+ "mcpServer.catalog.disconnectImpactNone": "Nenhum servidor virtual afetado foi encontrado.",
+ "mcpServer.catalog.disconnectImpactError": "Não foi possível verificar os servidores virtuais afetados. Você ainda pode desconectar este servidor.",
"mcpServer.catalog.disconnectSuccess": "{name} foi desconectado.",
"mcpServer.catalog.disconnectError": "Não foi possível desconectar {name}. Tente novamente.",
- "mcpServer.catalog.disconnectPending": "{name} ainda está desconectando. Atualize em breve.",
+ "mcpServer.catalog.disconnectPending": "{name} ainda está desconectando. Você pode tentar novamente em breve.",
"mcpServer.catalog.viewOptions": "Visualização do catálogo",
"mcpServer.catalog.transport": "Transporte",
"mcpServer.catalog.status": "Status",
diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx
index 2732f44f..6b056b13 100644
--- a/src/pages/ServerCatalog.test.tsx
+++ b/src/pages/ServerCatalog.test.tsx
@@ -243,6 +243,18 @@ describe("ServerCatalog", () => {
).toBeVisible();
});
+ it("reports an empty connectivity response as a test error", async () => {
+ const user = userEvent.setup();
+ mockTestCatalogServer.mockResolvedValue(null);
+ renderWithRouter(
);
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Test connection" }));
+
+ expect(await screen.findByText("Unable to test Globalping. Try again.")).toBeVisible();
+ expect(screen.queryByText(/status 0/i)).not.toBeInTheDocument();
+ });
+
it("keeps notifications from independent server mutations", async () => {
const user = userEvent.setup();
mockTestCatalogServer.mockResolvedValue({ statusCode: 503, latencyMs: 12 });
@@ -344,17 +356,34 @@ describe("ServerCatalog", () => {
await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
- expect(screen.getByText("Checking affected virtual servers…")).toHaveAttribute(
- "aria-live",
- "polite",
- );
- expect(screen.getByText("Checking affected virtual servers…")).toHaveAttribute(
- "aria-atomic",
- "true",
- );
+ const impactAnnouncement = screen.getByText("Checking affected virtual servers…", {
+ selector: "[aria-live]",
+ });
+ expect(impactAnnouncement).toHaveAttribute("aria-live", "polite");
+ expect(impactAnnouncement).toHaveAttribute("aria-atomic", "true");
resolvePreview!({ gatewayId: "gateway-globalping", servers: [] });
});
+ it("distinguishes an empty impact preview from a failed preview", async () => {
+ const user = userEvent.setup();
+ renderWithRouter(
);
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
+ expect(await screen.findByText("No affected virtual servers found.")).toBeVisible();
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ mockGetGatewayImpactPreview.mockRejectedValue(new Error("Preview unavailable"));
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
+
+ expect(
+ await screen.findByText(
+ "Could not check affected virtual servers. You can still disconnect this server.",
+ ),
+ ).toBeVisible();
+ });
+
it("waits for catalog state after an async disconnect", async () => {
const user = userEvent.setup();
const refetch = vi.fn().mockResolvedValue({
@@ -378,6 +407,28 @@ describe("ServerCatalog", () => {
expect(await screen.findByText("Globalping disconnected.")).toBeInTheDocument();
});
+ it("closes an accepted async disconnect and marks the card as disconnecting", async () => {
+ const user = userEvent.setup();
+ const refetch = vi.fn(() => new Promise
(() => {}));
+ mockUseQuery.mockReturnValue(queryResult({ refetch }));
+ mockDisconnectCatalogGateway.mockResolvedValue({
+ status: 202,
+ data: { status: "deleting" },
+ headers: new Headers({ "Retry-After": "0.001" }),
+ });
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Actions for Globalping" }));
+ await user.click(screen.getByRole("menuitem", { name: "Disconnect" }));
+ const dialog = await screen.findByRole("alertdialog");
+ await user.click(within(dialog).getByRole("button", { name: "Disconnect" }));
+
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
+ const acceptedNotification = await screen.findByText("Globalping is disconnecting.");
+ expect(acceptedNotification.closest('[role="status"]')).toBeVisible();
+ expect(screen.getByText("Disconnecting…")).toHaveAttribute("role", "status");
+ });
+
it("retries async disconnect status without sending DELETE again", async () => {
const user = userEvent.setup();
const refetch = vi
@@ -406,14 +457,18 @@ describe("ServerCatalog", () => {
await vi.advanceTimersByTimeAsync(60_000);
});
- expect(screen.getByText("Globalping is still disconnecting. Refresh shortly.")).toBeVisible();
+ expect(
+ screen.getByText("Globalping is still disconnecting. You can retry shortly."),
+ ).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
- expect(screen.getByText("Globalping is still disconnecting. Refresh shortly.")).toBeVisible();
+ expect(
+ screen.getByText("Globalping is still disconnecting. You can retry shortly."),
+ ).toBeVisible();
refetch.mockResolvedValue({
...response,
servers: [{ ...openConnected, is_registered: false, gateway_id: null }],
diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx
index a05cfed5..b75fc979 100644
--- a/src/pages/ServerCatalog.tsx
+++ b/src/pages/ServerCatalog.tsx
@@ -46,11 +46,13 @@ interface CatalogFilters {
interface RegistrationNotification {
id: string;
- type: "success" | "error";
+ type: "success" | "error" | "info";
message: string;
retryCatalogServer?: CatalogServer;
}
+type ImpactPreviewStatus = "idle" | "loading" | "loaded" | "error";
+
const DISCONNECT_POLL_TIMEOUT_MS = 30_000;
const DEFAULT_DISCONNECT_POLL_MS = 1_000;
@@ -312,13 +314,14 @@ export function ServerCatalog() {
>([]);
const [disconnectServer, setDisconnectServer] = useState(null);
const [impactPreview, setImpactPreview] = useState(null);
- const [impactPreviewLoading, setImpactPreviewLoading] = useState(false);
+ const [impactPreviewStatus, setImpactPreviewStatus] = useState("idle");
const impactRequestIdRef = useRef(0);
const disconnectPollAbortControllersRef = useRef(new Map());
const lastViewTriggerRef = useRef(null);
const pageHeadingRef = useRef(null);
const registrationNotificationRefs = useRef(new Map());
const notificationToFocusRef = useRef(null);
+ const shouldRedirectDisconnectCloseFocusRef = useRef(false);
const { data, error, isLoading, refetch, setData } = useQuery(CATALOG_PATH);
const canTest = !permissionsLoading && hasPermission("gateways.read");
const canDisconnect = !permissionsLoading && hasPermission("gateways.delete");
@@ -496,7 +499,22 @@ export function ServerCatalog() {
try {
const result = await testCatalogServer(server.url);
- const statusCode = result?.statusCode ?? 0;
+ if (!result) {
+ showRegistrationNotification(
+ {
+ id: `test:${server.id}`,
+ type: "error",
+ message: intl.formatMessage(
+ { id: "mcpServer.catalog.testError" },
+ { name: server.name },
+ ),
+ },
+ true,
+ );
+ return;
+ }
+
+ const statusCode = result.statusCode ?? 0;
const succeeded = statusCode >= 200 && statusCode < 300;
showRegistrationNotification(
{
@@ -506,7 +524,7 @@ export function ServerCatalog() {
{
id: succeeded ? "mcpServer.catalog.testSuccess" : "mcpServer.catalog.testFailure",
},
- { name: server.name, statusCode, latencyMs: result?.latencyMs ?? 0 },
+ { name: server.name, statusCode, latencyMs: result.latencyMs ?? 0 },
),
},
true,
@@ -543,21 +561,25 @@ export function ServerCatalog() {
setDisconnectServer(server);
setImpactPreview(null);
+ setImpactPreviewStatus("idle");
const requestId = ++impactRequestIdRef.current;
if (!canDisconnect) return;
- setImpactPreviewLoading(true);
+ setImpactPreviewStatus("loading");
void getGatewayImpactPreview(server.gateway_id)
.then((preview) => {
- if (impactRequestIdRef.current === requestId) setImpactPreview(preview);
+ if (impactRequestIdRef.current === requestId) {
+ setImpactPreview(preview);
+ setImpactPreviewStatus("loaded");
+ }
})
// Preview is optional. A permission or ownership race must not disclose
// the protected list or block a deletion the caller may perform.
.catch(() => {
- if (impactRequestIdRef.current === requestId) setImpactPreview(null);
- })
- .finally(() => {
- if (impactRequestIdRef.current === requestId) setImpactPreviewLoading(false);
+ if (impactRequestIdRef.current === requestId) {
+ setImpactPreview(null);
+ setImpactPreviewStatus("error");
+ }
});
},
[canDisconnect, isDisconnecting, isTesting],
@@ -568,7 +590,7 @@ export function ServerCatalog() {
impactRequestIdRef.current += 1;
setDisconnectServer(null);
setImpactPreview(null);
- setImpactPreviewLoading(false);
+ setImpactPreviewStatus("idle");
}, []);
const waitForCatalogDisconnect = useCallback(
@@ -634,7 +656,7 @@ export function ServerCatalog() {
showRegistrationNotification(
{
id: `disconnect:${server.id}`,
- type: "error",
+ type: error instanceof DisconnectPollTimeoutError ? "info" : "error",
message: intl.formatMessage(
{
id:
@@ -677,6 +699,20 @@ export function ServerCatalog() {
try {
const response = await disconnectCatalogGateway(gatewayId);
if (response.status === 202) {
+ setDisconnectServer(null);
+ setImpactPreview(null);
+ setImpactPreviewStatus("idle");
+ showRegistrationNotification(
+ {
+ id: `disconnect:${server.id}`,
+ type: "info",
+ message: intl.formatMessage(
+ { id: "mcpServer.catalog.disconnectAccepted" },
+ { name: server.name },
+ ),
+ },
+ true,
+ );
pollController = new AbortController();
disconnectPollAbortControllersRef.current.set(server.id, pollController);
await waitForCatalogDisconnect(
@@ -687,6 +723,7 @@ export function ServerCatalog() {
} else {
setData((current) => setCatalogServerRegistration(current, server.id, false, null));
void refreshCatalogSilently();
+ shouldRedirectDisconnectCloseFocusRef.current = true;
}
impactRequestIdRef.current += 1;
setDisconnectServer(null);
@@ -711,7 +748,7 @@ export function ServerCatalog() {
showRegistrationNotification(
{
id: `disconnect:${server.id}`,
- type: "error",
+ type: error instanceof DisconnectPollTimeoutError ? "info" : "error",
message: intl.formatMessage(
{
id:
@@ -748,6 +785,16 @@ export function ServerCatalog() {
window.setTimeout(() => lastViewTriggerRef.current?.focus(), 0);
}, []);
+ const handleDisconnectDialogCloseAutoFocus = useCallback((event: Event) => {
+ if (!shouldRedirectDisconnectCloseFocusRef.current) return;
+
+ // A synchronous delete swaps the actions trigger for Add, so Radix cannot
+ // restore focus to the removed trigger.
+ event.preventDefault();
+ shouldRedirectDisconnectCloseFocusRef.current = false;
+ pageHeadingRef.current?.focus();
+ }, []);
+
const handleRegistrationNotificationDismiss = useCallback(
(notificationId: string) => {
const notification = registrationNotificationRefs.current.get(notificationId);
@@ -878,23 +925,40 @@ export function ServerCatalog() {
{ name: disconnectServer?.name ?? "" },
)}
- {impactPreviewLoading && (
-
+
+ {impactPreviewStatus === "loading"
+ ? intl.formatMessage({ id: "mcpServer.catalog.disconnectImpactLoading" })
+ : ""}
+
+ {impactPreviewStatus === "loading" && (
+
{intl.formatMessage({ id: "mcpServer.catalog.disconnectImpactLoading" })}
)}
- {!impactPreviewLoading && impactPreview && impactPreview.servers.length > 0 && (
-
-
- {intl.formatMessage({ id: "mcpServer.catalog.disconnectImpact" })}
-
-
- {impactPreview.servers.map((server) => (
- - {server.name}
- ))}
-
-
+ {impactPreviewStatus === "loaded" && impactPreview?.servers.length === 0 && (
+
+ {intl.formatMessage({ id: "mcpServer.catalog.disconnectImpactNone" })}
+
+ )}
+ {impactPreviewStatus === "error" && (
+
+ {intl.formatMessage({ id: "mcpServer.catalog.disconnectImpactError" })}
+
)}
+ {impactPreviewStatus === "loaded" &&
+ impactPreview &&
+ impactPreview.servers.length > 0 && (
+
+
+ {intl.formatMessage({ id: "mcpServer.catalog.disconnectImpact" })}
+
+
+ {impactPreview.servers.map((server) => (
+ - {server.name}
+ ))}
+
+
+ )}
}
confirmLabel={intl.formatMessage({ id: "mcpServer.catalog.disconnect" })}
@@ -904,6 +968,7 @@ export function ServerCatalog() {
isLoading={disconnectServer ? disconnectingServerIds.has(disconnectServer.id) : false}
loadingLabel={intl.formatMessage({ id: "mcpServer.catalog.disconnecting" })}
closeOnConfirm={false}
+ onCloseAutoFocus={handleDisconnectDialogCloseAutoFocus}
/>
);
From 6035f4396693b7bd832f8f9b0242cd8b57971962 Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Fri, 28 Aug 2026 14:01:25 +0100
Subject: [PATCH 8/8] test: fix catalog registration CSRF fixture
Signed-off-by: Vishu Bhatnagar
---
e2e/server-catalog.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts
index 421bcfa7..e654804e 100644
--- a/e2e/server-catalog.spec.ts
+++ b/e2e/server-catalog.spec.ts
@@ -225,7 +225,7 @@ test.describe("Server catalog page", () => {
await expect(page.getByRole("heading", { name: "Globalping" })).toBeVisible();
});
- test("adds an open server then refreshes its gateway id", async ({ page }) => {
+ test("adds an open server then refreshes its gateway id", async ({ page, apiMock }) => {
let registered = false;
let catalogCalls = 0;
let registerCalls = 0;