This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 95f9c6ccd fix(studio): scope the tool playground by instance (#5575)
95f9c6ccd is described below

commit 95f9c6ccd425125ce613fa404f34121e421c0e22
Author: aias00 <[email protected]>
AuthorDate: Thu Oct 8 20:26:29 2026 -0700

    fix(studio): scope the tool playground by instance (#5575)
    
    The playground passed the physical cluster id where a Studio `instanceId` 
belongs, so
    `ToolDiscoveryService.findByName` threw INSTANCE_NOT_FOUND and 
`ToolController` injected the same
    wrong value into the tool arguments. Scope the selector by instance 
(`listInstances` plus
    `instance.name`) like the eight other call sites in the frontend; 
`ToolController` already accepts
    `instanceId`, so no backend change is needed.
---
 web/src/api/ai.test.ts                             |  6 +-
 web/src/api/ai.ts                                  |  4 +-
 web/src/i18n/translations.ts                       |  8 +--
 web/src/pages/ai/__tests__/AiPage.test.tsx         | 41 +++++++++--
 .../pages/ai/components/ToolPlaygroundModal.tsx    | 81 +++++++++++-----------
 5 files changed, 85 insertions(+), 55 deletions(-)

diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index df976d3fe..6029a35d4 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -586,10 +586,10 @@ describe('AI API', () => {
       expect(result).toEqual([]);
     });
 
-    it('scopes tool discovery to the selected cluster', async () => {
-      mock.onGet('/ai/tools', { params: { cluster: 'cluster-a' } }).reply(200, 
{ data: [] });
+    it('scopes tool discovery to the selected instance', async () => {
+      mock.onGet('/ai/tools', { params: { instanceId: 'instance-a' } 
}).reply(200, { data: [] });
 
-      await expect(listTools('cluster-a')).resolves.toEqual([]);
+      await expect(listTools('instance-a')).resolves.toEqual([]);
     });
 
     it('should handle server error', async () => {
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index e1b67c0d6..1b7d2aade 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -417,9 +417,9 @@ export async function attachRunStream(
   await consumeRunStream(response, handlers, 'AI run stream failed');
 }
 
-export async function listTools(cluster?: string) {
+export async function listTools(instanceId?: string) {
   const res = await client.get<{ data: McpTool[] }>('/ai/tools', {
-    params: cluster ? { cluster } : undefined,
+    params: instanceId ? { instanceId } : undefined,
   });
   return res.data.data;
 }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index f8f82a51f..8b6d93e41 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1842,7 +1842,7 @@ const translations: Record<string, Record<Lang, string>> 
= {
 
   // ─── AI tool playground ───
   'ai.tools.title': { zh: 'AI 工具', en: 'AI tools' },
-  'ai.tools.selectCluster': { zh: '选择集群', en: 'Select a cluster' },
+  'ai.tools.selectInstance': { zh: '选择实例', en: 'Select an instance' },
   'ai.tools.globalScope': { zh: '全局工具', en: 'Global tools' },
   'ai.tools.selectTool': { zh: '选择工具', en: 'Select a tool' },
   'ai.tools.inputLabel': { zh: '输入参数 (JSON)', en: 'Input (JSON)' },
@@ -1878,9 +1878,9 @@ const translations: Record<string, Record<Lang, string>> 
= {
     zh: 'AI 工具目录加载失败',
     en: 'Failed to load the AI tool catalog',
   },
-  'ai.clusterListLoadFailed': {
-    zh: '集群列表加载失败,已显示全局工具',
-    en: 'Failed to load clusters; showing global tools',
+  'ai.instanceListLoadFailed': {
+    zh: '实例列表加载失败,已显示全局工具',
+    en: 'Failed to load instances; showing global tools',
   },
   'ai.mode.title': { zh: '对话模式', en: 'Chat mode' },
   'ai.mode.chat': { zh: '对话', en: 'Chat' },
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 989277c5d..9c2ca4de1 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -36,7 +36,9 @@ import type {
   TimelineItem,
 } from '../../../api/aiEvents';
 import { listClusters, type ClusterInfo } from '../../../api/cluster';
+import type { Instance } from '../../../api/instance';
 import { getLlmConfig, getLlmModels } from '../../../api/llm';
+import { listInstances } from '../../../services/instanceService';
 import useAuthStore from '../../../stores/authStore';
 import { useEngineStore } from '../../../stores/engineStore';
 import AiPage from '../index';
@@ -83,6 +85,10 @@ vi.mock('../../../api/cluster', () => ({
   listClusters: vi.fn(),
 }));
 
+vi.mock('../../../services/instanceService', () => ({
+  listInstances: vi.fn(),
+}));
+
 vi.mock('../../../stores/dataModeStore', () => ({
   useDataModeStore: (selector: (state: typeof dataModeMocks) => unknown) => 
selector(dataModeMocks),
 }));
@@ -234,6 +240,7 @@ describe('AiPage', () => {
       stopReason: 'USER_STOP',
     });
     vi.mocked(listClusters).mockResolvedValue([]);
+    vi.mocked(listInstances).mockResolvedValue([]);
     vi.mocked(listTools).mockResolvedValue([]);
   });
 
@@ -517,6 +524,7 @@ describe('AiPage', () => {
 
     await user.click(screen.getByRole('button', { name: '工具' }));
     await waitFor(() => expect(listClusters).not.toHaveBeenCalled());
+    expect(listInstances).not.toHaveBeenCalled();
     expect(listTools).not.toHaveBeenCalled();
   });
 
@@ -571,7 +579,14 @@ describe('AiPage', () => {
   it('opensTheToolPlaygroundLoadsTheCatalogAndExecutesAToolTest', async () => {
     const user = userEvent.setup();
     vi.mocked(listClusters).mockResolvedValue([
-      { id: 'cluster-a', name: 'Cluster A' } as ClusterInfo,
+      { id: 'physical-cluster-a', name: 'Physical Cluster A' } as ClusterInfo,
+    ]);
+    vi.mocked(listInstances).mockResolvedValue([
+      {
+        id: 17,
+        name: 'studio-instance-a',
+        endpoint: 'nameserver-a:9876',
+      } as Instance,
     ]);
     vi.mocked(listTools).mockResolvedValue([
       {
@@ -586,25 +601,30 @@ describe('AiPage', () => {
         permission: 'cluster:read',
       },
     ]);
-    vi.mocked(executeTool).mockResolvedValue({ instanceId: 'cluster-a', 
capabilities: ['GRPC'] });
+    vi.mocked(executeTool).mockResolvedValue({
+      instanceId: 'studio-instance-a',
+      capabilities: ['GRPC'],
+    });
     renderPage();
     await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
 
     await user.click(screen.getByRole('button', { name: '工具' }));
     const dialog = await screen.findByRole('dialog', { name: 'AI 工具' });
-    await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-a'));
+    await waitFor(() => expect(listInstances).toHaveBeenCalledTimes(1));
+    expect(listClusters).not.toHaveBeenCalled();
+    await waitFor(() => 
expect(listTools).toHaveBeenCalledWith('studio-instance-a'));
     
expect(within(dialog).getByText('rmq.instance.capabilities')).toBeInTheDocument();
     expect(within(dialog).getByText('L1')).toBeInTheDocument();
 
     const toolInput = within(dialog).getByRole('textbox', { name: '工具参数 JSON' 
});
-    expect(toolInput).toHaveValue('{\n  "instanceId": "cluster-a"\n}');
+    expect(toolInput).toHaveValue('{\n  "instanceId": "studio-instance-a"\n}');
     await user.click(within(dialog).getByRole('button', { name: /执\s*行/ }));
 
     await waitFor(() =>
       expect(executeTool).toHaveBeenCalledWith(
         'rmq.instance.capabilities',
-        { instanceId: 'cluster-a' },
-        'cluster-a',
+        { instanceId: 'studio-instance-a' },
+        'studio-instance-a',
       ),
     );
     expect(await 
within(dialog).findByTestId('tool-result')).toHaveTextContent('"GRPC"');
@@ -613,7 +633,14 @@ describe('AiPage', () => {
   it('rejectsToolInputThatIsNotAJsonObjectTest', async () => {
     const user = userEvent.setup();
     vi.mocked(listClusters).mockResolvedValue([
-      { id: 'cluster-a', name: 'Cluster A' } as ClusterInfo,
+      { id: 'physical-cluster-a', name: 'Physical Cluster A' } as ClusterInfo,
+    ]);
+    vi.mocked(listInstances).mockResolvedValue([
+      {
+        id: 17,
+        name: 'studio-instance-a',
+        endpoint: 'nameserver-a:9876',
+      } as Instance,
     ]);
     vi.mocked(listTools).mockResolvedValue([
       { name: 'rmq.topic.list', description: 'List topics.', parameters: {} },
diff --git a/web/src/pages/ai/components/ToolPlaygroundModal.tsx 
b/web/src/pages/ai/components/ToolPlaygroundModal.tsx
index c9fabb876..20a7d5dea 100644
--- a/web/src/pages/ai/components/ToolPlaygroundModal.tsx
+++ b/web/src/pages/ai/components/ToolPlaygroundModal.tsx
@@ -19,11 +19,11 @@ import { useCallback, useEffect, useRef, useState } from 
'react';
 import { Flex, Input, Modal, Select, Space, Tag, Typography, message, theme } 
from 'antd';
 import { useLang } from '../../../i18n/LangContext';
 import { executeTool, listTools, type McpTool } from '../../../api/ai';
-import { listClusters } from '../../../api/cluster';
 import InfoBanner from '../../../components/InfoBanner';
+import { listInstances } from '../../../services/instanceService';
 
 /**
- * The manual tool playground: pick a cluster scope, pick a tool, edit the 
JSON arguments, run it.
+ * The manual tool playground: pick an instance scope, pick a tool, edit the 
JSON arguments, run it.
  *
  * Kept even though the agent now calls these same tools itself, for two 
reasons. It is the
  * human-driven escape hatch — an operator who does not trust an answer can 
run the exact call the
@@ -31,19 +31,19 @@ import InfoBanner from '../../../components/InfoBanner';
  * loop, which is how a broken catalog entry gets told apart from a confused 
agent.
  *
  * ─── Self-contained on purpose ─────────────────────────────────
- * Cluster list, catalog, selection, arguments and result all live here; the 
page it was extracted
+ * Instance list, catalog, selection, arguments and result all live here; the 
page it was extracted
  * from carried eleven pieces of state for it. The one thing that crosses the 
boundary is
  * {@link ToolPlaygroundModalProps.onToolsLoaded}, because the transcript's 
tool blocks resolve their
  * risk level from the same catalog: it is already loaded, and fetching it 
again per block would be
  * both wasteful and a second source of truth.
  *
  * ─── Stale catalog guard ───────────────────────────────────────
- * Switching cluster scope fires a new `listTools` while the previous one may 
still be in flight. The
+ * Switching instance scope fires a new `listTools` while the previous one may 
still be in flight. The
  * monotonic `toolLoadRequestRef` is re-read after every await, so a slow 
response for the OLD scope
- * can neither replace the catalog nor re-select a tool that belongs to 
another cluster.
+ * can neither replace the catalog nor re-select a tool that belongs to 
another instance.
  */
 
-/** Sentinel scope for the tools that are not bound to a cluster. */
+/** Sentinel scope for the tools that are not bound to an instance. */
 const GLOBAL_TOOL_SCOPE = '__global__';
 
 const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -71,10 +71,10 @@ const defaultSchemaValue = (schema: unknown): unknown => {
 
 /**
  * Seed the argument editor with every REQUIRED argument, so the operator 
edits a value instead of
- * recalling a parameter name. `instanceId` is pre-filled from the selected 
cluster because that is
+ * recalling a parameter name. `instanceId` is pre-filled from the selected 
instance because that is
  * the one argument the scope selector already knows.
  */
-const buildToolInputTemplate = (tool: McpTool, cluster?: string): string => {
+const buildToolInputTemplate = (tool: McpTool, instanceId?: string): string => 
{
   const required = Array.isArray(tool.parameters.required)
     ? tool.parameters.required.filter((field): field is string => typeof field 
=== 'string')
     : [];
@@ -82,7 +82,7 @@ const buildToolInputTemplate = (tool: McpTool, cluster?: 
string): string => {
   const input = Object.fromEntries(
     required.map((field) => [
       field,
-      field === 'instanceId' && cluster ? cluster : 
defaultSchemaValue(properties[field]),
+      field === 'instanceId' && instanceId ? instanceId : 
defaultSchemaValue(properties[field]),
     ]),
   );
   return JSON.stringify(input, null, 2);
@@ -113,9 +113,9 @@ const ToolPlaygroundModal = ({
   const { token } = theme.useToken();
   const [tools, setTools] = useState<McpTool[]>([]);
   const [toolsLoading, setToolsLoading] = useState(false);
-  const [clusterOptions, setClusterOptions] = useState<{ value: string; label: 
string }[]>([]);
-  const [clustersLoading, setClustersLoading] = useState(false);
-  const [selectedClusterId, setSelectedClusterId] = useState('');
+  const [instanceOptions, setInstanceOptions] = useState<{ value: string; 
label: string }[]>([]);
+  const [instancesLoading, setInstancesLoading] = useState(false);
+  const [selectedInstanceId, setSelectedInstanceId] = useState('');
   const [selectedToolName, setSelectedToolName] = useState('');
   const [toolInput, setToolInput] = useState('{}');
   const [toolResult, setToolResult] = useState<unknown>(undefined);
@@ -125,29 +125,29 @@ const ToolPlaygroundModal = ({
   const bootstrappedRef = useRef(false);
 
   const selectTool = useCallback(
-    (name: string, availableTools: McpTool[] = tools, clusterId: string = 
selectedClusterId) => {
+    (name: string, availableTools: McpTool[] = tools, instanceId: string = 
selectedInstanceId) => {
       const tool = availableTools.find((item) => item.name === name);
       setSelectedToolName(name);
-      setToolInput(tool ? buildToolInputTemplate(tool, clusterId) : '{}');
+      setToolInput(tool ? buildToolInputTemplate(tool, instanceId) : '{}');
       setToolResult(undefined);
     },
-    [selectedClusterId, tools],
+    [selectedInstanceId, tools],
   );
 
   const loadTools = useCallback(
-    async (clusterId: string) => {
+    async (instanceId: string) => {
       const requestId = ++toolLoadRequestRef.current;
       setSelectedToolName('');
       setToolResult(undefined);
       setToolsLoading(true);
       try {
-        const availableTools = await listTools(clusterId || undefined);
+        const availableTools = await listTools(instanceId || undefined);
         if (requestId !== toolLoadRequestRef.current) return;
         setTools(availableTools);
         onToolsLoaded?.(availableTools);
         // Skip deprecated entries: they are listed so an operator can 
recognise one, not to be run.
         const firstTool = availableTools.find((tool) => !tool.deprecated);
-        if (firstTool) selectTool(firstTool.name, availableTools, clusterId);
+        if (firstTool) selectTool(firstTool.name, availableTools, instanceId);
       } catch {
         if (requestId === toolLoadRequestRef.current) {
           setTools([]);
@@ -161,21 +161,24 @@ const ToolPlaygroundModal = ({
   );
 
   const bootstrap = useCallback(async () => {
-    let clusterId = '';
-    setClustersLoading(true);
+    let instanceId = '';
+    setInstancesLoading(true);
     try {
-      const clusters = await listClusters();
-      const options = clusters.map((cluster) => ({ value: cluster.id, label: 
cluster.name }));
-      setClusterOptions(options);
-      clusterId = options[0]?.value ?? '';
-      setSelectedClusterId(clusterId);
+      const instances = await listInstances();
+      const options = instances.map((instance) => ({
+        value: instance.name,
+        label: instance.name,
+      }));
+      setInstanceOptions(options);
+      instanceId = options[0]?.value ?? '';
+      setSelectedInstanceId(instanceId);
     } catch {
-      message.warning(t('ai.clusterListLoadFailed'));
+      message.warning(t('ai.instanceListLoadFailed'));
     } finally {
-      setClustersLoading(false);
+      setInstancesLoading(false);
     }
 
-    await loadTools(clusterId);
+    await loadTools(instanceId);
   }, [loadTools, t]);
 
   // Opening the modal is what loads the catalog, and only the first time: the 
page may deep-link
@@ -188,11 +191,11 @@ const ToolPlaygroundModal = ({
     void bootstrap();
   }, [bootstrap, disabled, open]);
 
-  const handleClusterChange = useCallback(
+  const handleInstanceChange = useCallback(
     async (scope: string) => {
-      const clusterId = scope === GLOBAL_TOOL_SCOPE ? '' : scope;
-      setSelectedClusterId(clusterId);
-      await loadTools(clusterId);
+      const instanceId = scope === GLOBAL_TOOL_SCOPE ? '' : scope;
+      setSelectedInstanceId(instanceId);
+      await loadTools(instanceId);
     },
     [loadTools],
   );
@@ -215,14 +218,14 @@ const ToolPlaygroundModal = ({
     setToolExecuting(true);
     setToolResult(undefined);
     try {
-      setToolResult(await executeTool(selectedToolName, parsedInput, 
selectedClusterId));
+      setToolResult(await executeTool(selectedToolName, parsedInput, 
selectedInstanceId));
       message.success(t('ai.tools.executeSuccess'));
     } catch (error) {
       message.error(error instanceof Error ? error.message : 
t('ai.tools.executeFailed'));
     } finally {
       setToolExecuting(false);
     }
-  }, [selectedClusterId, selectedToolName, t, toolExecuting, toolInput]);
+  }, [selectedInstanceId, selectedToolName, t, toolExecuting, toolInput]);
 
   const handleClose = useCallback(() => {
     // Invalidate an in-flight catalog load: its response would otherwise land 
on a closed modal and
@@ -253,14 +256,14 @@ const ToolPlaygroundModal = ({
         {disabled && <InfoBanner description={t('ai.mockToolsUnavailable')} />}
 
         <Select
-          aria-label={t('ai.tools.selectCluster')}
-          loading={clustersLoading}
+          aria-label={t('ai.tools.selectInstance')}
+          loading={instancesLoading}
           disabled={disabled}
-          value={selectedClusterId || GLOBAL_TOOL_SCOPE}
-          onChange={(scope) => void handleClusterChange(scope)}
+          value={selectedInstanceId || GLOBAL_TOOL_SCOPE}
+          onChange={(scope) => void handleInstanceChange(scope)}
           options={[
             { value: GLOBAL_TOOL_SCOPE, label: t('ai.tools.globalScope') },
-            ...clusterOptions,
+            ...instanceOptions,
           ]}
         />
 

Reply via email to