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 f07dfbf9 feat: add AI resource tools (#642)
f07dfbf9 is described below
commit f07dfbf964e17b68689b73f4d53721631b3d0b0a
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 07:27:49 2026 -0700
feat: add AI resource tools (#642)
* [Studio] Add AI resource tools
* fix: normalize AI draft model
---
.../ops/ai/tool/ConsumerGroupListToolHandler.java | 88 ++++++++
.../ops/ai/tool/DashboardSummaryToolHandler.java | 111 ++++++++++
.../studio/ops/ai/tool/TopicListToolHandler.java | 82 +++++++
.../src/main/resources/tool-catalog/rmq-tools.yaml | 229 ++++++++++++++++++++
.../studio/ops/ai/tool/ToolCatalogTest.java | 7 +-
.../studio/ops/ai/tool/ToolGatewayServiceTest.java | 236 ++++++++++++++++++++-
web/src/pages/ai/chatDraft.test.ts | 8 +-
web/src/pages/ai/chatDraft.ts | 3 +-
8 files changed, 754 insertions(+), 10 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandler.java
new file mode 100644
index 00000000..db7fd84c
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandler.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.topic.MetadataService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class ConsumerGroupListToolHandler implements ToolHandler {
+
+ private static final String NAME = "rmq.group.list";
+
+ private final MetadataService metadataService;
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public Object execute(Map<String, Object> input) {
+ String clusterId = (String) input.get("cluster");
+ String search = (String) input.get("search");
+ return metadataService.listConsumerGroups(clusterId, search).stream()
+ .map(ConsumerGroupListToolHandler::safeProjection)
+ .toList();
+ }
+
+ private static Map<String, Object> safeProjection(ConsumerGroupVO group) {
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("name", require(group.getName(), "name"));
+ result.put("namespace", blankIfNull(group.getNamespace()));
+ result.put("clusterId", blankIfNull(group.getClusterId()));
+ result.put("subscriptionMode", requiredEnumName(
+ group.getSubscriptionMode(), "subscriptionMode",
group.getName()));
+ result.put("consumeType", requiredEnumName(
+ group.getConsumeType(), "consumeType", group.getName()));
+ result.put("onlineInstances", group.getOnlineInstances());
+ result.put("totalLag", group.getTotalLag());
+ result.put("subscribedTopics", copyList(group.getSubscribedTopics()));
+ result.put("retryMaxTimes", group.getRetryMaxTimes());
+ return result;
+ }
+
+ private static String requiredEnumName(Enum<?> value, String field, String
groupName) {
+ if (value == null) {
+ throw new IllegalStateException("Consumer group " + field
+ + " is unavailable: " + groupName);
+ }
+ return value.name();
+ }
+
+ private static String require(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalStateException("Consumer group " + field + " is
unavailable");
+ }
+ return value;
+ }
+
+ private static List<String> copyList(List<String> value) {
+ return value == null ? List.of() : List.copyOf(value);
+ }
+
+ private static String blankIfNull(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java
new file mode 100644
index 00000000..0d8708e7
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO;
+import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO;
+import org.apache.rocketmq.studio.ops.dashboard.DashboardService;
+import org.apache.rocketmq.studio.ops.dashboard.DashboardStatsVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class DashboardSummaryToolHandler implements ToolHandler {
+
+ private static final String NAME = "rmq.dashboard.summary";
+
+ private final DashboardService dashboardService;
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public Object execute(Map<String, Object> input) {
+ String clusterId = (String) input.get("cluster");
+ DashboardDataVO dashboard = dashboardService.getDashboard();
+ ClusterOverviewVO cluster = clusters(dashboard).stream()
+ .filter(item -> clusterId.equals(item.getId()))
+ .findFirst()
+ .orElseThrow(() -> new BusinessException(
+ 404, "Dashboard cluster not found: " + clusterId));
+
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("cluster", clusterProjection(cluster));
+ result.put("stats", statsProjection(dashboard.getStats()));
+ return result;
+ }
+
+ private static List<ClusterOverviewVO> clusters(DashboardDataVO dashboard)
{
+ if (dashboard == null || dashboard.getClusters() == null) {
+ return List.of();
+ }
+ return dashboard.getClusters();
+ }
+
+ private static Map<String, Object> clusterProjection(ClusterOverviewVO
cluster) {
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("id", cluster.getId());
+ result.put("name", cluster.getName());
+ result.put("type", requiredEnumName(cluster.getType(), "type",
cluster.getId()));
+ result.put("status", requiredEnumName(
+ cluster.getStatus(), "status", cluster.getId()));
+ result.put("brokers", cluster.getBrokers());
+ result.put("proxies", cluster.getProxies());
+ result.put("topics", cluster.getTopics());
+ result.put("groups", cluster.getGroups());
+ result.put("tpsIn", cluster.getTpsIn());
+ result.put("tpsOut", cluster.getTpsOut());
+ result.put("version", cluster.getVersion());
+ result.put("throughput", cluster.getThroughput() == null
+ ? List.of()
+ : cluster.getThroughput());
+ return result;
+ }
+
+ private static Map<String, Object> statsProjection(DashboardStatsVO stats)
{
+ DashboardStatsVO safeStats = stats == null ? new DashboardStatsVO() :
stats;
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("totalClusters", safeStats.getTotalClusters());
+ result.put("healthyClusters", safeStats.getHealthyClusters());
+ result.put("totalBrokers", safeStats.getTotalBrokers());
+ result.put("totalProxies", safeStats.getTotalProxies());
+ result.put("totalNameServers", safeStats.getTotalNameServers());
+ result.put("totalTopics", safeStats.getTotalTopics());
+ result.put("totalConsumerGroups", safeStats.getTotalConsumerGroups());
+ result.put("totalMessagesToday", safeStats.getTotalMessagesToday());
+ result.put("messagesPerSecond", safeStats.getMessagesPerSecond());
+ result.put("tpsIn", safeStats.getTpsIn());
+ result.put("tpsOut", safeStats.getTpsOut());
+ return result;
+ }
+
+ private static String requiredEnumName(Enum<?> value, String field, String
clusterId) {
+ if (value == null) {
+ throw new IllegalStateException(
+ "Cluster " + field + " is unavailable: " + clusterId);
+ }
+ return value.name();
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandler.java
new file mode 100644
index 00000000..6eb730c2
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandler.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.apache.rocketmq.studio.instance.topic.MetadataService;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class TopicListToolHandler implements ToolHandler {
+
+ private static final String NAME = "rmq.topic.list";
+
+ private final MetadataService metadataService;
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public Object execute(Map<String, Object> input) {
+ String clusterId = (String) input.get("cluster");
+ String type = (String) input.get("type");
+ String search = (String) input.get("search");
+ return metadataService.listTopics(clusterId, type, search).stream()
+ .map(TopicListToolHandler::safeProjection)
+ .toList();
+ }
+
+ private static Map<String, Object> safeProjection(TopicVO topic) {
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("name", require(topic.getName(), "name"));
+ result.put("namespace", blankIfNull(topic.getNamespace()));
+ result.put("clusterId", blankIfNull(topic.getClusterId()));
+ result.put("type", requiredEnumName(topic.getType(), "type",
topic.getName()));
+ result.put("writeQueues", topic.getWriteQueues());
+ result.put("readQueues", topic.getReadQueues());
+ result.put("perm", requiredEnumName(topic.getPerm(), "perm",
topic.getName()));
+ result.put("messageCount", topic.getMessageCount());
+ result.put("tps", topic.getTps());
+ result.put("consumerGroupCount", topic.getConsumerGroupCount());
+ return result;
+ }
+
+ private static String requiredEnumName(Enum<?> value, String field, String
topicName) {
+ if (value == null) {
+ throw new IllegalStateException("Topic " + field + " is
unavailable: " + topicName);
+ }
+ return value.name();
+ }
+
+ private static String require(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalStateException("Topic " + field + " is
unavailable");
+ }
+ return value;
+ }
+
+ private static String blankIfNull(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml
b/server/src/main/resources/tool-catalog/rmq-tools.yaml
index 4327667c..1ce2d105 100644
--- a/server/src/main/resources/tool-catalog/rmq-tools.yaml
+++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml
@@ -74,3 +74,232 @@ tools:
type: string
viewHint: object
deprecated: false
+ - name: rmq.dashboard.summary
+ cli:
+ resource: dashboard
+ verb: summary
+ description: Get the Studio dashboard summary for one RocketMQ cluster.
+ riskLevel: L1
+ permission: dashboard:read
+ requiredCapabilities: []
+ inputSchema:
+ type: object
+ required:
+ - cluster
+ additionalProperties: false
+ properties:
+ cluster:
+ type: string
+ minLength: 1
+ outputSchema:
+ type: object
+ required:
+ - cluster
+ - stats
+ additionalProperties: false
+ properties:
+ cluster:
+ type: object
+ required:
+ - id
+ - name
+ - type
+ - status
+ - brokers
+ - proxies
+ - topics
+ - groups
+ - tpsIn
+ - tpsOut
+ - version
+ - throughput
+ additionalProperties: false
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ type:
+ type: string
+ status:
+ type: string
+ brokers:
+ type: integer
+ proxies:
+ type: integer
+ topics:
+ type: integer
+ groups:
+ type: integer
+ tpsIn:
+ type: integer
+ tpsOut:
+ type: integer
+ version:
+ type: string
+ throughput:
+ type: array
+ items:
+ type: integer
+ stats:
+ type: object
+ required:
+ - totalClusters
+ - healthyClusters
+ - totalBrokers
+ - totalProxies
+ - totalNameServers
+ - totalTopics
+ - totalConsumerGroups
+ - totalMessagesToday
+ - messagesPerSecond
+ - tpsIn
+ - tpsOut
+ additionalProperties: false
+ properties:
+ totalClusters:
+ type: integer
+ healthyClusters:
+ type: integer
+ totalBrokers:
+ type: integer
+ totalProxies:
+ type: integer
+ totalNameServers:
+ type: integer
+ totalTopics:
+ type: integer
+ totalConsumerGroups:
+ type: integer
+ totalMessagesToday:
+ type: integer
+ messagesPerSecond:
+ type: integer
+ tpsIn:
+ type: integer
+ tpsOut:
+ type: integer
+ viewHint: object
+ deprecated: false
+ - name: rmq.topic.list
+ cli:
+ resource: topic
+ verb: list
+ description: List RocketMQ topics in one Studio cluster.
+ riskLevel: L1
+ permission: topic:read
+ requiredCapabilities:
+ - REMOTING
+ inputSchema:
+ type: object
+ required:
+ - cluster
+ additionalProperties: false
+ properties:
+ cluster:
+ type: string
+ minLength: 1
+ type:
+ type: string
+ minLength: 1
+ search:
+ type: string
+ minLength: 1
+ outputSchema:
+ type: array
+ items:
+ type: object
+ required:
+ - name
+ - namespace
+ - clusterId
+ - type
+ - writeQueues
+ - readQueues
+ - perm
+ - messageCount
+ - tps
+ - consumerGroupCount
+ additionalProperties: false
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ clusterId:
+ type: string
+ type:
+ type: string
+ writeQueues:
+ type: integer
+ readQueues:
+ type: integer
+ perm:
+ type: string
+ messageCount:
+ type: integer
+ tps:
+ type: number
+ consumerGroupCount:
+ type: integer
+ viewHint: table
+ deprecated: false
+ - name: rmq.group.list
+ cli:
+ resource: group
+ verb: list
+ description: List RocketMQ consumer groups in one Studio cluster.
+ riskLevel: L1
+ permission: group:read
+ requiredCapabilities:
+ - REMOTING
+ inputSchema:
+ type: object
+ required:
+ - cluster
+ additionalProperties: false
+ properties:
+ cluster:
+ type: string
+ minLength: 1
+ search:
+ type: string
+ minLength: 1
+ outputSchema:
+ type: array
+ items:
+ type: object
+ required:
+ - name
+ - namespace
+ - clusterId
+ - subscriptionMode
+ - consumeType
+ - onlineInstances
+ - totalLag
+ - subscribedTopics
+ - retryMaxTimes
+ additionalProperties: false
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ clusterId:
+ type: string
+ subscriptionMode:
+ type: string
+ consumeType:
+ type: string
+ onlineInstances:
+ type: integer
+ totalLag:
+ type: integer
+ subscribedTopics:
+ type: array
+ items:
+ type: string
+ retryMaxTimes:
+ type: integer
+ viewHint: table
+ deprecated: false
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
index c35a11e1..50f10175 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
@@ -38,7 +38,12 @@ class ToolCatalogTest {
assertThat(catalog.getMinimumClientVersion()).isEqualTo("1.0.0");
assertThat(catalog.getDigest()).matches("[0-9a-f]{64}");
assertThat(catalog.list()).extracting(ToolDefinition::getName)
- .containsExactly("rmq.cluster.list", "rmq.capabilities");
+ .containsExactly(
+ "rmq.cluster.list",
+ "rmq.capabilities",
+ "rmq.dashboard.summary",
+ "rmq.topic.list",
+ "rmq.group.list");
assertThat(catalog.find("rmq.cluster.list")).isPresent();
assertThat(catalog.find("rmq.unknown")).isEmpty();
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
index 13dd6fe7..46a0699d 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
@@ -21,8 +21,19 @@ import
org.apache.rocketmq.studio.cluster.broker.ClusterService;
import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
+import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.topic.MetadataService;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
import org.apache.rocketmq.studio.ops.ai.AiToolVO;
+import org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO;
+import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO;
+import org.apache.rocketmq.studio.ops.dashboard.DashboardService;
+import org.apache.rocketmq.studio.ops.dashboard.DashboardStatsVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
@@ -43,19 +54,35 @@ class ToolGatewayServiceTest {
private ToolCatalog catalog;
private ClusterService clusterService;
+ private DashboardService dashboardService;
+ private MetadataService metadataService;
private CapabilityResolver capabilityResolver;
private ClusterListToolHandler clusterListHandler;
private CapabilitiesToolHandler capabilitiesHandler;
+ private DashboardSummaryToolHandler dashboardSummaryHandler;
+ private TopicListToolHandler topicListHandler;
+ private ConsumerGroupListToolHandler consumerGroupListHandler;
private ToolGatewayService gateway;
@BeforeEach
void setUp() {
catalog = canonicalCatalog();
clusterService = mock(ClusterService.class);
+ dashboardService = mock(DashboardService.class);
+ metadataService = mock(MetadataService.class);
capabilityResolver = new CapabilityResolver(clusterService);
clusterListHandler = new ClusterListToolHandler(clusterService);
capabilitiesHandler = new CapabilitiesToolHandler(clusterService,
capabilityResolver);
- gateway = gateway(catalog, clusterListHandler, capabilitiesHandler);
+ dashboardSummaryHandler = new
DashboardSummaryToolHandler(dashboardService);
+ topicListHandler = new TopicListToolHandler(metadataService);
+ consumerGroupListHandler = new
ConsumerGroupListToolHandler(metadataService);
+ gateway = gateway(
+ catalog,
+ clusterListHandler,
+ capabilitiesHandler,
+ dashboardSummaryHandler,
+ topicListHandler,
+ consumerGroupListHandler);
}
@Test
@@ -72,7 +99,12 @@ class ToolGatewayServiceTest {
assertThat(gateway.discover("cluster-v5"))
.extracting(AiToolVO::getName)
- .containsExactly("rmq.cluster.list", "rmq.capabilities");
+ .containsExactly(
+ "rmq.cluster.list",
+ "rmq.capabilities",
+ "rmq.dashboard.summary",
+ "rmq.topic.list",
+ "rmq.group.list");
}
@Test
@@ -113,6 +145,73 @@ class ToolGatewayServiceTest {
"ROCKETMQ_5")));
}
+ @Test
+ @SuppressWarnings("unchecked")
+ void executesDashboardSummaryWithADataMinimizingProjection() {
+
when(dashboardService.getDashboard()).thenReturn(DashboardDataVO.builder()
+ .stats(DashboardStatsVO.builder()
+ .totalClusters(1)
+ .healthyClusters(1)
+ .totalBrokers(2)
+ .totalProxies(1)
+ .totalNameServers(1)
+ .totalTopics(3)
+ .totalConsumerGroups(4)
+ .totalMessagesToday(500L)
+ .messagesPerSecond(6L)
+ .tpsIn(7L)
+ .tpsOut(8L)
+ .build())
+ .clusters(List.of(ClusterOverviewVO.builder()
+ .id("cluster-v5")
+ .name("test")
+ .type(ClusterType.V5_PROXY_CLUSTER)
+ .status(ClusterStatus.healthy)
+ .brokers(2)
+ .proxies(1)
+ .topics(3)
+ .groups(4)
+ .tpsIn(7)
+ .tpsOut(8)
+ .version("5.2.0")
+ .throughput(List.of(1, 2, 3))
+ .build()))
+ .build());
+
+ Object output = gateway.execute(
+ "rmq.dashboard.summary", Map.of("cluster", "cluster-v5"));
+
+ Map<String, Object> result = (Map<String, Object>) output;
+ assertThat(result).containsOnlyKeys("cluster", "stats");
+ Map<String, Object> cluster = (Map<String, Object>)
result.get("cluster");
+ assertThat(cluster).containsEntry("id", "cluster-v5");
+ assertThat(cluster).containsEntry("name", "test");
+ assertThat(cluster).containsEntry("type", "V5_PROXY_CLUSTER");
+ assertThat(cluster).containsEntry("status", "healthy");
+ assertThat(cluster).containsEntry("brokers", 2);
+ assertThat(cluster).containsEntry("proxies", 1);
+ assertThat(cluster).containsEntry("topics", 3);
+ assertThat(cluster).containsEntry("groups", 4);
+ assertThat(cluster).containsEntry("tpsIn", 7);
+ assertThat(cluster).containsEntry("tpsOut", 8);
+ assertThat(cluster).containsEntry("version", "5.2.0");
+ assertThat(cluster).containsEntry("throughput", List.of(1, 2, 3));
+ assertThat(cluster).doesNotContainKeys("endpoint");
+ assertThat((Map<String, Object>)
result.get("stats")).containsAllEntriesOf(Map.of(
+ "totalMessagesToday", 500L,
+ "messagesPerSecond", 6L,
+ "tpsIn", 7L,
+ "tpsOut", 8L));
+ }
+
+ @Test
+ void rejectsDashboardSummaryWithoutRequiredClusterBeforeHandlerRuns() {
+ assertThatThrownBy(() -> gateway.execute("rmq.dashboard.summary",
Map.of()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("input validation failed");
+ verifyNoInteractions(dashboardService);
+ }
+
@Test
void resolvesCapabilitiesForEveryExistingClusterType() {
when(clusterService.getCluster("v4")).thenReturn(cluster("v4",
ClusterType.V4_DIRECT));
@@ -152,6 +251,73 @@ class ToolGatewayServiceTest {
.hasMessageContaining("Cluster type is unavailable");
}
+ @Test
+ void executesTopicListThroughADataMinimizingProjection() {
+
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+ TopicVO topic = topic();
+ topic.setRemark("do-not-expose");
+ when(metadataService.listTopics("cluster-v5", "NORMAL", "order"))
+ .thenReturn(List.of(topic));
+
+ Object output = gateway.execute("rmq.topic.list", Map.of(
+ "cluster", "cluster-v5",
+ "type", "NORMAL",
+ "search", "order"));
+
+ assertThat(output).isEqualTo(List.of(Map.of(
+ "name", "order-topic",
+ "namespace", "default",
+ "clusterId", "cluster-v5",
+ "type", "NORMAL",
+ "writeQueues", 8,
+ "readQueues", 8,
+ "perm", "RW",
+ "messageCount", 1200L,
+ "tps", 23.5D,
+ "consumerGroupCount", 3)));
+ assertThat(output.toString()).doesNotContain("do-not-expose");
+ }
+
+ @Test
+ void rejectsTopicListWithoutAClusterBeforeHandlerRuns() {
+ assertThatThrownBy(() -> gateway.execute("rmq.topic.list",
Map.of("type", "NORMAL")))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("input validation failed");
+ verifyNoInteractions(metadataService);
+ }
+
+ @Test
+ void executesConsumerGroupListThroughADataMinimizingProjection() {
+
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+ ConsumerGroupVO group = consumerGroup();
+ group.setDelaySeconds(30);
+ when(metadataService.listConsumerGroups("cluster-v5",
"order")).thenReturn(List.of(group));
+
+ Object output = gateway.execute("rmq.group.list", Map.of(
+ "cluster", "cluster-v5",
+ "search", "order"));
+
+ assertThat(output).isEqualTo(List.of(Map.of(
+ "name", "cg-order",
+ "namespace", "default",
+ "clusterId", "cluster-v5",
+ "subscriptionMode", "Push",
+ "consumeType", "CLUSTERING",
+ "onlineInstances", 2,
+ "totalLag", 42L,
+ "subscribedTopics", List.of("order-topic"),
+ "retryMaxTimes", 16)));
+ assertThat(output.toString()).doesNotContain("delaySeconds");
+ }
+
+ @Test
+ void rejectsConsumerGroupListWithoutAClusterBeforeHandlerRuns() {
+ assertThatThrownBy(() -> gateway.execute("rmq.group.list", Map.of()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("input validation failed");
+ verifyNoInteractions(metadataService);
+ }
+
@Test
void rejectsCapabilitiesExecutionWhenClusterTypeIsMissing() {
when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null));
@@ -201,7 +367,13 @@ class ToolGatewayServiceTest {
ToolCatalog l2Catalog = ToolCatalog.load(
new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)),
new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
- ToolGatewayService l2Gateway = gateway(l2Catalog, clusterListHandler,
capabilitiesHandler);
+ ToolGatewayService l2Gateway = gateway(
+ l2Catalog,
+ clusterListHandler,
+ capabilitiesHandler,
+ dashboardSummaryHandler,
+ topicListHandler,
+ consumerGroupListHandler);
assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list",
Map.of()))
.isInstanceOf(BusinessException.class)
@@ -212,7 +384,13 @@ class ToolGatewayServiceTest {
@Test
void failsStartupForDuplicateHandlerNames() {
assertThatThrownBy(() -> gateway(
- catalog, clusterListHandler, clusterListHandler,
capabilitiesHandler))
+ catalog,
+ clusterListHandler,
+ clusterListHandler,
+ capabilitiesHandler,
+ dashboardSummaryHandler,
+ topicListHandler,
+ consumerGroupListHandler))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("duplicate handler");
}
@@ -241,7 +419,12 @@ class ToolGatewayServiceTest {
new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
assertThatThrownBy(() -> gateway(
- invalidCatalog, clusterListHandler, capabilitiesHandler))
+ invalidCatalog,
+ clusterListHandler,
+ capabilitiesHandler,
+ dashboardSummaryHandler,
+ topicListHandler,
+ consumerGroupListHandler))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("input schema")
.hasMessageContaining("rmq.cluster.list");
@@ -263,7 +446,12 @@ class ToolGatewayServiceTest {
new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
assertThatThrownBy(() -> gateway(
- invalidCatalog, clusterListHandler, capabilitiesHandler))
+ invalidCatalog,
+ clusterListHandler,
+ capabilitiesHandler,
+ dashboardSummaryHandler,
+ topicListHandler,
+ consumerGroupListHandler))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("input schema")
.hasMessageContaining("rmq.cluster.list");
@@ -283,7 +471,12 @@ class ToolGatewayServiceTest {
}
};
ToolGatewayService invalidGateway = gateway(
- catalog, invalidClusterListHandler, capabilitiesHandler);
+ catalog,
+ invalidClusterListHandler,
+ capabilitiesHandler,
+ dashboardSummaryHandler,
+ topicListHandler,
+ consumerGroupListHandler);
assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list",
Map.of()))
.isInstanceOf(IllegalStateException.class)
@@ -323,4 +516,33 @@ class ToolGatewayServiceTest {
cluster.setId(id);
return cluster;
}
+
+ private static TopicVO topic() {
+ TopicVO topic = new TopicVO();
+ topic.setName("order-topic");
+ topic.setNamespace("default");
+ topic.setClusterId("cluster-v5");
+ topic.setType(TopicType.NORMAL);
+ topic.setWriteQueues(8);
+ topic.setReadQueues(8);
+ topic.setPerm(TopicPerm.RW);
+ topic.setMessageCount(1200L);
+ topic.setTps(23.5D);
+ topic.setConsumerGroupCount(3);
+ return topic;
+ }
+
+ private static ConsumerGroupVO consumerGroup() {
+ ConsumerGroupVO group = new ConsumerGroupVO();
+ group.setName("cg-order");
+ group.setNamespace("default");
+ group.setClusterId("cluster-v5");
+ group.setSubscriptionMode(SubscriptionMode.Push);
+ group.setConsumeType(ConsumeType.CLUSTERING);
+ group.setOnlineInstances(2);
+ group.setTotalLag(42L);
+ group.setSubscribedTopics(List.of("order-topic"));
+ group.setRetryMaxTimes(16);
+ return group;
+ }
}
diff --git a/web/src/pages/ai/chatDraft.test.ts
b/web/src/pages/ai/chatDraft.test.ts
index 6f1e09e8..d7d89f42 100644
--- a/web/src/pages/ai/chatDraft.test.ts
+++ b/web/src/pages/ai/chatDraft.test.ts
@@ -20,7 +20,7 @@ import { getChatDraft } from './chatDraft';
describe('AI chat draft navigation state', () => {
it('normalizes a prompt and preserves a selected model', () => {
- expect(getChatDraft({ prompt: ' 检查集群状态 ', model: 'qwen3.7-max'
})).toEqual({
+ expect(getChatDraft({ prompt: ' 检查集群状态 ', model: ' qwen3.7-max '
})).toEqual({
prompt: '检查集群状态',
model: 'qwen3.7-max',
});
@@ -31,4 +31,10 @@ describe('AI chat draft navigation state', () => {
expect(getChatDraft({ prompt: ' ' })).toBeNull();
expect(getChatDraft({ prompt: 42 })).toBeNull();
});
+
+ it('drops empty model values after normalization', () => {
+ expect(getChatDraft({ prompt: '检查集群状态', model: ' ' })).toEqual({
+ prompt: '检查集群状态',
+ });
+ });
});
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts
index 115692ad..2ad1a936 100644
--- a/web/src/pages/ai/chatDraft.ts
+++ b/web/src/pages/ai/chatDraft.ts
@@ -24,9 +24,10 @@ export function getChatDraft(state: unknown): ChatDraft |
null {
if (typeof state !== 'object' || state === null) return null;
const candidate = state as Record<string, unknown>;
if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return
null;
+ const model = typeof candidate.model === 'string' ? candidate.model.trim() :
'';
return {
prompt: candidate.prompt.trim(),
- ...(typeof candidate.model === 'string' && candidate.model ? { model:
candidate.model } : {}),
+ ...(model ? { model } : {}),
};
}