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,
]}
/>