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 206314b9a fix(ai): make topic and group tools work for cluster scope 
and cloud data (#4115)
206314b9a is described below

commit 206314b9a3a9701683bb6462abad5739ab059a84
Author: Zhao Jianing <[email protected]>
AuthorDate: Wed Sep 9 20:47:09 2026 +0800

    fix(ai): make topic and group tools work for cluster scope and cloud data 
(#4115)
    
    The AI resource tool chain was broken end to end: every one of 
`rmq.topic.list` and `rmq.group.list` failed, and for cloud instances the 
failure happened even once routing was fixed. Four separate defects, each fixed 
here, plus one stale test that hid a contract drift.
    
    **Routing.** Both handlers passed the tool's `cluster` input into the 
`instanceId` slot — `listTopicsPage(clusterId, null, ...)` — so the request 
never reached the `!hasText(instanceId) && hasText(clusterId)` cluster-scoped 
branch. Because `cluster` is `required` with `minLength: 1` in the catalog, the 
legacy branch was unreachable and both tools threw `404 Instance not found: 
DefaultCluster` on every call. The arguments are now `(null, clusterId, ...)`. 
Regression introduced by #3052.
    
    **Output schema.** `rmq-tools.yaml` declared `cluster.proxies`, 
`stats.totalProxies`, `stats.totalNameServers` and `items[].totalLag` as `type: 
integer` while keeping them `required`, but the handlers deliberately emit 
`null` to mean "unknown" — `totalLag` does so for `ConsumerLagResolver.UNKNOWN` 
since #3988. `ToolGatewayService.validateOutput` preserves null keys through 
`valueToTree`, so listing any group with an unknown lag raised 
`IllegalStateException` and returned 500. Those fo [...]
    
    **Cloud topic type and perm.** The Aliyun converter never set `perm` at 
all, so every Aliyun topic carried a null perm and 
`TopicListToolHandler.safeProjection` threw on its first `requiredEnumName` 
call. Both cloud `toTopicType` implementations also returned null for a blank 
or unrecognised type. They now guarantee non-null: `TopicPerm.RW` for Aliyun 
(its ListTopics API returns no permission field, and console-created cloud 
topics are read-write, matching the Tencent provider) and `T [...]
    
    **Cloud consumer group subscription mode.** Neither cloud provider set 
`subscriptionMode`, and Aliyun's `toConsumeType` returned null unless 
`messageModel` was exactly "Clustering", so `requiredEnumName` threw for every 
cloud consumer group. Both now set `SubscriptionMode.Push` — the pinned 
`alibabacloud-rocketmq20220801` response body has no subscription-mode field at 
all, and cloud TCP groups are push consumers — and `toConsumeType` returns 
`CLUSTERING`.
    
    **Test contract drift.** 
`AliyunInstanceProviderTest.getGroupProgressShouldMapLagRowsTest` still 
expected a per-topic row *and* a `broker="total"` aggregate row, but 
`AliyunConverters.toQueueProgressRows` has only emitted the aggregate as a 
fallback when there are no topic rows since #2907. The expectation was stale, 
not the production code: emitting both would double-count lag, because 
`CloudRocketMqBusinessMetricsCollector` sums `getDiffTotal()` across every row 
it is given. The tes [...]
    
    Folded in from #4117, #4134, #4135 and #4137, all by the same author and 
all part of this one chain; those PRs are closed as superseded. Merging any 
single one of them would not have made the tools usable.
---
 .../ops/ai/tool/ConsumerGroupListToolHandler.java  |  2 +-
 .../studio/ops/ai/tool/TopicListToolHandler.java   |  2 +-
 .../studio/provider/alibaba/AliyunConverters.java  | 26 ++++---
 .../provider/tencent/TencentInstanceProvider.java  | 13 +++-
 .../src/main/resources/tool-catalog/rmq-tools.yaml |  8 +-
 .../ai/tool/ConsumerGroupListToolHandlerTest.java  | 75 ++++++++++++++++++
 .../studio/ops/ai/tool/ToolGatewayServiceTest.java | 67 +++++++++++++++-
 .../ops/ai/tool/TopicListToolHandlerTest.java      | 78 +++++++++++++++++++
 .../alibaba/AliyunInstanceProviderTest.java        | 90 ++++++++++++++++++++--
 .../tencent/TencentInstanceProviderTest.java       | 22 ++++++
 10 files changed, 355 insertions(+), 28 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
index fcaf58381..87792fed5 100644
--- 
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
@@ -45,7 +45,7 @@ public class ConsumerGroupListToolHandler implements 
ToolHandler {
         String clusterId = (String) input.get("cluster");
         String search = (String) input.get("search");
         PageResult<ConsumerGroupVO> page = 
metadataService.listConsumerGroupsPage(
-                clusterId, null, search, ToolListPagination.page(input), 
ToolListPagination.pageSize(input));
+                null, clusterId, search, ToolListPagination.page(input), 
ToolListPagination.pageSize(input));
         return ToolListPagination.pagedResult(page, page.getItems().stream()
                 .map(ConsumerGroupListToolHandler::safeProjection)
                 .toList());
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
index 910cff251..50f669f76 100644
--- 
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
@@ -44,7 +44,7 @@ public class TopicListToolHandler implements ToolHandler {
         String type = (String) input.get("type");
         String search = (String) input.get("search");
         PageResult<TopicVO> page = metadataService.listTopicsPage(
-                clusterId, null, type, search, ToolListPagination.page(input), 
ToolListPagination.pageSize(input));
+                null, clusterId, type, search, ToolListPagination.page(input), 
ToolListPagination.pageSize(input));
         return ToolListPagination.pagedResult(page, page.getItems().stream()
                 .map(TopicListToolHandler::safeProjection)
                 .toList());
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
index e8a49cf89..f658e48ce 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
@@ -29,6 +29,8 @@ import 
com.aliyun.sdk.service.rocketmq20220801.models.ListTopicSubscriptionsResp
 import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponseBody;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.DeliveryStatus;
+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.util.SubscriptionFilterModes;
 import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
@@ -118,6 +120,9 @@ final class AliyunConverters {
         vo.setName(data.getTopicName());
         vo.setInstanceId(studioInstanceId);
         vo.setType(toTopicType(data.getMessageType()));
+        // Aliyun's ListTopics API does not return permissions; 
console-created cloud
+        // topics are read-write, matching the Tencent provider's mapping.
+        vo.setPerm(TopicPerm.RW);
         vo.setRemark(data.getRemark());
         vo.setGmtCreate(parseDateTime(data.getCreateTime()));
         vo.setGmtModified(parseDateTime(data.getUpdateTime()));
@@ -127,8 +132,8 @@ final class AliyunConverters {
     }
 
     static TopicType toTopicType(String messageType) {
-        if (messageType == null) {
-            return null;
+        if (messageType == null || messageType.isBlank()) {
+            return TopicType.NORMAL;
         }
         switch (messageType.toUpperCase(Locale.ROOT)) {
             case "NORMAL":
@@ -140,7 +145,10 @@ final class AliyunConverters {
             case "TRANSACTION":
                 return TopicType.TRANSACTION;
             default:
-                return null;
+                // Unknown message types fall back to NORMAL so read paths (web
+                // detail, AI rmq.topic.list) never see a null type, matching 
the
+                // Apache provider's parseTopicType fallback.
+                return TopicType.NORMAL;
         }
     }
 
@@ -157,22 +165,20 @@ final class AliyunConverters {
         vo.setName(data.getConsumerGroupId());
         vo.setInstanceId(studioInstanceId);
         vo.setConsumeType(toConsumeType(data.getMessageModel()));
+        // Aliyun's messageModel carries the consume model, not the 
subscription mode; cloud TCP
+        // consumer groups are push consumers. Read paths (web detail, AI 
rmq.group.list) require
+        // a non-null subscriptionMode, mirroring the Apache provider 
invariant.
+        vo.setSubscriptionMode(SubscriptionMode.Push);
         vo.setGmtCreate(parseDateTime(data.getCreateTime()));
         vo.setGmtModified(parseDateTime(data.getUpdateTime()));
         return vo;
     }
 
     static ConsumeType toConsumeType(String messageModel) {
-        if (messageModel == null) {
-            return null;
-        }
-        if ("Clustering".equalsIgnoreCase(messageModel)) {
-            return ConsumeType.CLUSTERING;
-        }
         if ("Broadcasting".equalsIgnoreCase(messageModel)) {
             return ConsumeType.BROADCASTING;
         }
-        return null;
+        return ConsumeType.CLUSTERING;
     }
 
     static List<QueueProgressVO> 
toQueueProgressRows(GetConsumerGroupLagResponseBody.Data data) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
index 07c536404..9730693a2 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
@@ -48,6 +48,7 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.DeliveryStatus;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+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;
@@ -944,6 +945,9 @@ public class TencentInstanceProvider implements 
InstanceProvider {
         group.setClusterId(item.getClusterIdV4());
         group.setNamespace(item.getNamespaceV4());
         group.setConsumeType(toConsumeType(item.getConsumeMessageOrderly()));
+        // Tencent consumer groups are TCP push consumers; read paths (web 
detail,
+        // AI rmq.group.list) require a non-null subscriptionMode.
+        group.setSubscriptionMode(SubscriptionMode.Push);
         group.setDeliveryOrderType(item.getConsumeMessageOrderly() == null || 
!item.getConsumeMessageOrderly()
                 ? "Concurrently" : "Orderly");
         group.setRetryMaxTimes(toInt(item.getMaxRetryTimes()));
@@ -1036,12 +1040,15 @@ public class TencentInstanceProvider implements 
InstanceProvider {
 
     private static TopicType toTopicType(String raw) {
         if (!StringUtils.hasText(raw)) {
-            return null;
+            return TopicType.NORMAL;
         }
         try {
             return TopicType.valueOf(raw.trim().toUpperCase(Locale.ROOT));
-        } catch (IllegalArgumentException ignored) {
-            return null;
+        } catch (IllegalArgumentException ex) {
+            // Unknown topic types fall back to NORMAL so read paths (web 
detail,
+            // AI rmq.topic.list) never see a null type, matching the Apache
+            // provider's parseTopicType fallback.
+            return TopicType.NORMAL;
         }
     }
 
diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml 
b/server/src/main/resources/tool-catalog/rmq-tools.yaml
index 647467a18..e822461e9 100644
--- a/server/src/main/resources/tool-catalog/rmq-tools.yaml
+++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml
@@ -126,7 +126,7 @@ tools:
             brokers:
               type: integer
             proxies:
-              type: integer
+              type: [integer, 'null']
             topics:
               type: integer
             groups:
@@ -164,9 +164,9 @@ tools:
             totalBrokers:
               type: integer
             totalProxies:
-              type: integer
+              type: [integer, 'null']
             totalNameServers:
-              type: integer
+              type: [integer, 'null']
             totalTopics:
               type: integer
             totalConsumerGroups:
@@ -332,7 +332,7 @@ tools:
               onlineInstances:
                 type: integer
               totalLag:
-                type: integer
+                type: [integer, 'null']
               subscribedTopics:
                 type: array
                 items:
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandlerTest.java
new file mode 100644
index 000000000..ec3701e88
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandlerTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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.domain.PageResult;
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
+import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.topic.MetadataService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ConsumerGroupListToolHandlerTest {
+
+    @Mock
+    private MetadataService metadataService;
+
+    @InjectMocks
+    private ConsumerGroupListToolHandler handler;
+
+    @Test
+    void executeShouldRouteClusterToClusterScopedRead() {
+        ConsumerGroupVO group = new ConsumerGroupVO();
+        group.setName("cg-orders");
+        group.setClusterId("DefaultCluster");
+        group.setSubscriptionMode(SubscriptionMode.Push);
+        group.setConsumeType(ConsumeType.CLUSTERING);
+        group.setOnlineInstances(3);
+        group.setTotalLag(42L);
+        group.setSubscribedTopics(List.of("orders"));
+        group.setRetryMaxTimes(16);
+        when(metadataService.listConsumerGroupsPage(isNull(), 
eq("DefaultCluster"), eq("order"), eq(1), eq(20)))
+                .thenReturn(PageResult.of(List.of(group), 1L, 1, 20));
+
+        Object result = handler.execute(Map.of("cluster", "DefaultCluster", 
"search", "order"));
+
+        Map<?, ?> page = (Map<?, ?>) result;
+        assertThat(page.get("total")).isEqualTo(1L);
+        List<?> items = (List<?>) page.get("items");
+        assertThat(items).hasSize(1);
+        Map<?, ?> row = (Map<?, ?>) items.get(0);
+        assertThat(row.get("name")).isEqualTo("cg-orders");
+        assertThat(row.get("subscriptionMode")).isEqualTo("Push");
+        assertThat(row.get("consumeType")).isEqualTo("CLUSTERING");
+        assertThat(row.get("totalLag")).isEqualTo(42L);
+        verify(metadataService).listConsumerGroupsPage(isNull(), 
eq("DefaultCluster"), eq("order"), eq(1), eq(20));
+    }
+}
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 1b88dde62..b78aec568 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
@@ -41,6 +41,7 @@ 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.apache.rocketmq.studio.provider.apache.ConsumerLagResolver;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -54,6 +55,8 @@ import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -339,6 +342,48 @@ class ToolGatewayServiceTest {
                 "tpsOut", 8L));
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    void executesDashboardSummaryWhenTopologyCountsAreUnavailable() {
+        // Mirrors the V5/degraded provider paths (RocketMQDashboardProvider) 
and
+        // 
DashboardControllerTest.getDashboardShouldPreserveUnavailableTopologyCounts:
+        // proxy/name-server counts are deliberately null when the topology is 
unavailable.
+        
when(dashboardService.getDashboard()).thenReturn(DashboardDataVO.builder()
+                .stats(DashboardStatsVO.builder()
+                        .totalClusters(1)
+                        .healthyClusters(1)
+                        .totalBrokers(2)
+                        .totalProxies(null)
+                        .totalNameServers(null)
+                        .totalTopics(3)
+                        .totalConsumerGroups(4)
+                        .build())
+                .clusters(List.of(ClusterOverviewVO.builder()
+                        .id("cluster-v5")
+                        .name("test")
+                        .type(ClusterType.V5_PROXY_CLUSTER)
+                        .status(ClusterStatus.healthy)
+                        .brokers(2)
+                        .proxies(null)
+                        .topics(3)
+                        .groups(4)
+                        .tpsIn(7)
+                        .tpsOut(8)
+                        .version("5.2.0")
+                        .build()))
+                .build());
+
+        Object output = gateway.execute(
+                "rmq.dashboard.summary", Map.of("cluster", "cluster-v5"));
+
+        Map<String, Object> result = (Map<String, Object>) output;
+        Map<String, Object> cluster = (Map<String, Object>) 
result.get("cluster");
+        assertThat(cluster).containsEntry("proxies", null);
+        Map<String, Object> stats = (Map<String, Object>) result.get("stats");
+        assertThat(stats).containsEntry("totalProxies", null);
+        assertThat(stats).containsEntry("totalNameServers", null);
+    }
+
     @Test
     void rejectsDashboardSummaryWithoutRequiredClusterBeforeHandlerRuns() {
         assertThatThrownBy(() -> gateway.execute("rmq.dashboard.summary", 
Map.of()))
@@ -391,7 +436,7 @@ class ToolGatewayServiceTest {
         
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
         TopicVO topic = topic();
         topic.setRemark("do-not-expose");
-        when(metadataService.listTopicsPage("cluster-v5", null, "NORMAL", 
"order", 2, 20))
+        when(metadataService.listTopicsPage(null, "cluster-v5", "NORMAL", 
"order", 2, 20))
                 .thenReturn(PageResult.of(List.of(topic), 101, 2, 20));
 
         Object output = gateway.execute("rmq.topic.list", Map.of(
@@ -432,7 +477,7 @@ class ToolGatewayServiceTest {
         
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
         ConsumerGroupVO group = consumerGroup();
         group.setDelaySeconds(30);
-        when(metadataService.listConsumerGroupsPage("cluster-v5", null, 
"order", 2, 20))
+        when(metadataService.listConsumerGroupsPage(null, "cluster-v5", 
"order", 2, 20))
                 .thenReturn(PageResult.of(List.of(group), 101, 2, 20));
 
         Object output = gateway.execute("rmq.group.list", Map.of(
@@ -458,6 +503,24 @@ class ToolGatewayServiceTest {
         assertThat(output.toString()).doesNotContain("delaySeconds");
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    void executesConsumerGroupListWhenLagIsUnknown() {
+        
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+        ConsumerGroupVO group = consumerGroup();
+        group.setTotalLag(ConsumerLagResolver.UNKNOWN);
+        when(metadataService.listConsumerGroupsPage(any(), any(), any(), 
anyInt(), anyInt()))
+                .thenReturn(PageResult.of(List.of(group), 1, 1, 20));
+
+        Object output = gateway.execute("rmq.group.list", Map.of(
+                "cluster", "cluster-v5", "search", "order", "page", 1, 
"pageSize", 20));
+
+        Map<String, Object> page = (Map<String, Object>) output;
+        List<Map<String, Object>> items = (List<Map<String, Object>>) 
page.get("items");
+        assertThat(items).hasSize(1);
+        assertThat(items.get(0)).containsEntry("totalLag", null);
+    }
+
     @Test
     void rejectsConsumerGroupListWithoutAClusterBeforeHandlerRuns() {
         assertThatThrownBy(() -> gateway.execute("rmq.group.list", Map.of()))
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandlerTest.java
new file mode 100644
index 000000000..73d7883f0
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandlerTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.domain.PageResult;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+import org.apache.rocketmq.studio.instance.topic.MetadataService;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TopicListToolHandlerTest {
+
+    @Mock
+    private MetadataService metadataService;
+
+    @InjectMocks
+    private TopicListToolHandler handler;
+
+    @Test
+    void executeShouldRouteClusterToClusterScopedRead() {
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setClusterId("DefaultCluster");
+        topic.setType(TopicType.NORMAL);
+        topic.setPerm(TopicPerm.RW);
+        topic.setWriteQueues(8);
+        topic.setReadQueues(8);
+        topic.setMessageCount(100L);
+        topic.setTps(1.5D);
+        topic.setConsumerGroupCount(2);
+        when(metadataService.listTopicsPage(isNull(), eq("DefaultCluster"), 
eq("NORMAL"), eq("order"), eq(1), eq(20)))
+                .thenReturn(PageResult.of(List.of(topic), 1L, 1, 20));
+
+        Object result = handler.execute(Map.of(
+                "cluster", "DefaultCluster", "type", "NORMAL", "search", 
"order"));
+
+        Map<?, ?> page = (Map<?, ?>) result;
+        assertThat(page.get("total")).isEqualTo(1L);
+        assertThat(page.get("page")).isEqualTo(1);
+        assertThat(page.get("size")).isEqualTo(20);
+        List<?> items = (List<?>) page.get("items");
+        assertThat(items).hasSize(1);
+        Map<?, ?> row = (Map<?, ?>) items.get(0);
+        assertThat(row.get("name")).isEqualTo("orders");
+        assertThat(row.get("type")).isEqualTo("NORMAL");
+        assertThat(row.get("perm")).isEqualTo("RW");
+        verify(metadataService).listTopicsPage(isNull(), eq("DefaultCluster"), 
eq("NORMAL"), eq("order"), eq(1), eq(20));
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
index fb45c4936..0ae1136cb 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
@@ -37,7 +37,9 @@ import 
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetRequest;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetResponse;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetResponseBody;
 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.InstanceVendor;
+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.InstanceRepository;
@@ -129,11 +131,12 @@ class AliyunInstanceProviderTest {
         assertThat(all).hasSize(3);
         assertThat(all.get(0).getName()).isEqualTo("topic-normal");
         assertThat(all.get(0).getType()).isEqualTo(TopicType.NORMAL);
+        assertThat(all.get(0).getPerm()).isEqualTo(TopicPerm.RW);
         assertThat(all.get(0).getInstanceId()).isEqualTo(STUDIO_INSTANCE_PK);
         assertThat(all.get(0).getWriteQueues()).isZero();
         assertThat(all.get(0).getReadQueues()).isZero();
         assertThat(all.get(0).getRemark()).isEqualTo("remark-topic-normal");
-        assertThat(all.get(2).getType()).isNull();
+        assertThat(all.get(2).getType()).isEqualTo(TopicType.NORMAL);
 
         List<TopicVO> fifos = provider.listTopics(STUDIO_INSTANCE_ID, "FIFO", 
null);
 
@@ -141,6 +144,24 @@ class AliyunInstanceProviderTest {
         assertThat(fifos.get(0).getType()).isEqualTo(TopicType.FIFO);
     }
 
+    @Test
+    void listTopicsShouldGuaranteeTypeAndPermForAiToolProjectionTest() {
+        stubInstance();
+        stubCallThrough();
+        
when(asyncClient.listTopics(any(ListTopicsRequest.class))).thenReturn(CompletableFuture.completedFuture(
+                topicsResponse(
+                        topicRow("topic-untyped", null),
+                        topicRow("topic-unknown", "NEW_TYPE"))));
+
+        List<TopicVO> topics = provider.listTopics(STUDIO_INSTANCE_ID, null, 
null);
+
+        assertThat(topics).hasSize(2);
+        assertThat(topics).allSatisfy(topic -> {
+            assertThat(topic.getType()).isEqualTo(TopicType.NORMAL);
+            assertThat(topic.getPerm()).isEqualTo(TopicPerm.RW);
+        });
+    }
+
     @Test
     void listTopicsShouldTraversePastLegacyFivePageCapTest() {
         stubInstance();
@@ -222,6 +243,37 @@ class AliyunInstanceProviderTest {
         assertThat(groups.get(0).getName()).isEqualTo("GID_test");
         
assertThat(groups.get(0).getInstanceId()).isEqualTo(STUDIO_INSTANCE_PK);
         
assertThat(groups.get(0).getConsumeType()).isEqualTo(ConsumeType.CLUSTERING);
+        
assertThat(groups.get(0).getSubscriptionMode()).isEqualTo(SubscriptionMode.Push);
+    }
+
+    @Test
+    void listConsumerGroupsShouldFallBackWhenMessageModelMissingTest() {
+        stubInstance();
+        stubCallThrough();
+        ListConsumerGroupsResponse response = 
ListConsumerGroupsResponse.create().toBuilder()
+                .statusCode(200)
+                .body(ListConsumerGroupsResponseBody.builder()
+                        .data(ListConsumerGroupsResponseBody.Data.builder()
+                                
.list(java.util.Arrays.asList(ListConsumerGroupsResponseBody.List.builder()
+                                        .consumerGroupId("GID_plain")
+                                        .status("RUNNING")
+                                        .build()))
+                                .pageNumber(1L)
+                                .pageSize(100L)
+                                .totalCount(1L)
+                                .build())
+                        .build())
+                .build();
+        when(asyncClient.listConsumerGroups(any()))
+                .thenReturn(CompletableFuture.completedFuture(response));
+
+        List<ConsumerGroupVO> groups = 
provider.listConsumerGroups(STUDIO_INSTANCE_ID, null);
+
+        assertThat(groups).singleElement().satisfies(group -> {
+            // read paths (web detail, AI rmq.group.list) require both enums 
to be non-null
+            
assertThat(group.getConsumeType()).isEqualTo(ConsumeType.CLUSTERING);
+            
assertThat(group.getSubscriptionMode()).isEqualTo(SubscriptionMode.Push);
+        });
     }
 
     @Test
@@ -313,17 +365,41 @@ class AliyunInstanceProviderTest {
 
         List<QueueProgressVO> rows = 
provider.getGroupProgress(STUDIO_INSTANCE_ID, "GID_test");
 
-        assertThat(rows).hasSize(2);
+        // with a topic breakdown the aggregate total row is dropped, 
otherwise callers
+        // that sum the rows would report the same lag twice
+        assertThat(rows).hasSize(1);
         QueueProgressVO topicRow = rows.stream()
                 .filter(row -> "topic:topic-a".equals(row.getBroker()))
                 .findFirst()
                 .orElseThrow();
         assertThat(topicRow.getDiffTotal()).isEqualTo(42L);
-        QueueProgressVO totalRow = rows.stream()
-                .filter(row -> "total".equals(row.getBroker()))
-                .findFirst()
-                .orElseThrow();
-        assertThat(totalRow.getDiffTotal()).isEqualTo(100L);
+        assertThat(rows).noneMatch(row -> "total".equals(row.getBroker()));
+    }
+
+    @Test
+    void getGroupProgressShouldFallBackToTotalRowWithoutTopicBreakdownTest() {
+        stubInstance();
+        stubCallThrough();
+        GetConsumerGroupLagResponse response = 
GetConsumerGroupLagResponse.create().toBuilder()
+                .statusCode(200)
+                .body(GetConsumerGroupLagResponseBody.builder()
+                        .data(GetConsumerGroupLagResponseBody.Data.builder()
+                                .consumerGroupId("GID_test")
+                                
.totalLag(GetConsumerGroupLagResponseBody.TotalLag.builder()
+                                        .readyCount(100L)
+                                        .build())
+                                .build())
+                        .build())
+                .build();
+        when(asyncClient.getConsumerGroupLag(any()))
+                .thenReturn(CompletableFuture.completedFuture(response));
+
+        List<QueueProgressVO> rows = 
provider.getGroupProgress(STUDIO_INSTANCE_ID, "GID_test");
+
+        assertThat(rows).singleElement().satisfies(row -> {
+            assertThat(row.getBroker()).isEqualTo("total");
+            assertThat(row.getDiffTotal()).isEqualTo(100L);
+        });
     }
 
     @Test
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
index 7b2844ba9..2f7d98b4a 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
@@ -44,8 +44,10 @@ import 
com.tencentcloudapi.trocket.v20230308.models.SubscriptionData;
 import com.tencentcloudapi.trocket.v20230308.models.TopicItem;
 import com.tencentcloudapi.trocket.v20230308.TrocketClient;
 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.DeliveryStatus;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+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.InstanceRepository;
@@ -210,6 +212,25 @@ class TencentInstanceProviderTest {
         
assertThat(captor.getValue().getFilters()[1].getValues()).containsExactly("FIFO");
     }
 
+    @Test
+    void listTopicsShouldFallBackToNormalTypeWhenTopicTypeMissingTest() throws 
Exception {
+        when(client.DescribeTopicList(any())).thenAnswer(invocation -> {
+            DescribeTopicListResponse response = new 
DescribeTopicListResponse();
+            response.setData(new TopicItem[]{
+                    topicItem("orders-untyped", null, 4L),
+                    topicItem("orders-unknown", "NEW_TYPE", 4L)});
+            return response;
+        });
+        DescribeTopicResponse detail = new DescribeTopicResponse();
+        when(client.DescribeTopic(any())).thenReturn(detail);
+
+        List<TopicVO> topics = provider.listTopics(STUDIO_INSTANCE_ID, null, 
null);
+
+        assertThat(topics).hasSize(2);
+        assertThat(topics).allSatisfy(topic -> 
assertThat(topic.getType()).isEqualTo(TopicType.NORMAL));
+        assertThat(topics).allSatisfy(topic -> 
assertThat(topic.getPerm()).isEqualTo(TopicPerm.RW));
+    }
+
     @Test
     void listTopicsPageShouldUseTencentNativePaginationAndFiltersTest() throws 
Exception {
         TopicItem item = topicItem("orders-fifo-10000", "FIFO", 8L);
@@ -536,6 +557,7 @@ class TencentInstanceProviderTest {
         assertThat(groups.get(0).getRetryMaxTimes()).isEqualTo(16);
         assertThat(groups.get(0).getGmtCreate()).isNotNull();
         
assertThat(groups.get(0).getConsumeType()).isEqualTo(ConsumeType.CLUSTERING);
+        
assertThat(groups.get(0).getSubscriptionMode()).isEqualTo(SubscriptionMode.Push);
         assertThat(groups.get(0).getInstances()).isNotNull().isEmpty();
     }
 

Reply via email to