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 288876e0 feat(tencent): support Topic management on Tencent Cloud 
RocketMQ 5.x instances (#1535)
288876e0 is described below

commit 288876e0558d944f222c8399ba4bbd983376185f
Author: Lei Zhiyuan <[email protected]>
AuthorDate: Tue Aug 11 11:51:10 2026 +0800

    feat(tencent): support Topic management on Tencent Cloud RocketMQ 5.x 
instances (#1535)
    
    * fix: resolve build and startup failures on the studio branch
    
    Fixes several issues that blocked the project from compiling and booting:
    
    - AlertService: import java.util.regex.Pattern used by metric/duration 
patterns
    - RocketMQMetadataProvider / RocketMQDashboardProvider: import 
SystemTopicFilter
    - RocketMQDLQProvider: MessageExt#getUserProperties() does not exist in
      rocketmq-tools 5.5.0; read properties via MessageExt#getProperties()
    - RocketMQMessageProvider: DefaultMQAdminExt#viewMessage(String) was 
removed;
      resolve the message by offset id to derive the trace window
    - GrafanaDashboardService: mark the public constructor @Autowired so Spring
      picks it over the package-private two-arg constructor (No default
      constructor found at startup)
    
    * feat(tencent): support topic management on Tencent Cloud RocketMQ 5.x
    
    Wire the Tencent Cloud provider (Trocket v20230308 OpenAPI) to the instance
    topic CRUD surface:
    
    - listTopics: page through DescribeTopicList, filter by type/name/remark, 
and
      enrich created/updated times per topic via DescribeTopic (millisecond ts)
    - createTopic / updateTopic / deleteTopic via Create/Modify/DeleteTopic
    - getTopicConsumers via DescribeTopic subscription data
    - countTopics via a cheap list path (no N+1 detail lookups)
    - validate types (reject LiteTopic), queue counts and cloud binding
    
    Also harden the topic page date formatter so missing timestamps render as 
"-"
    instead of 1970-01-01, and move the no-instance reset out of the synchronous
    effect body to satisfy react-hooks/set-state-in-effect.
---
 .../metrics/grafana/GrafanaDashboardService.java   |   2 +
 .../rocketmq/studio/ops/alert/AlertService.java    |   1 +
 .../provider/apache/RocketMQDLQProvider.java       |   2 +-
 .../provider/apache/RocketMQDashboardProvider.java |   1 +
 .../provider/apache/RocketMQMessageProvider.java   |   2 +-
 .../provider/apache/RocketMQMetadataProvider.java  |   1 +
 .../provider/tencent/TencentInstanceProvider.java  | 298 ++++++++++++++++++++-
 .../tencent/TencentInstanceProviderTest.java       | 178 +++++++++---
 web/src/pages/instance/topic.tsx                   |  16 +-
 9 files changed, 450 insertions(+), 51 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
index c16f2226..e0c5dfdd 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.core.io.Resource;
 import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
 import org.springframework.core.io.support.ResourcePatternResolver;
@@ -48,6 +49,7 @@ public class GrafanaDashboardService {
     private final ObjectMapper objectMapper;
     private final ResourcePatternResolver resourceResolver;
 
+    @Autowired
     public GrafanaDashboardService(ObjectMapper objectMapper) {
         this(objectMapper, new PathMatchingResourcePatternResolver());
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index a163bd76..250b73f0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -30,6 +30,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
+import java.util.regex.Pattern;
 
 @Slf4j
 @Service
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index c0872f75..2338d4c7 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -270,7 +270,7 @@ public class RocketMQDLQProvider implements DLQProvider {
             }
             // Copy user properties from the original message, skipping 
system-reserved
             // keys to avoid conflicts with broker-internal properties.
-            Map<String, String> userProperties = 
deadLetter.getUserProperties();
+            Map<String, String> userProperties = deadLetter.getProperties();
             if (userProperties != null) {
                 for (Map.Entry<String, String> entry : 
userProperties.entrySet()) {
                     String key = entry.getKey();
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
index d31603d7..cd9fd14d 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
@@ -42,6 +42,7 @@ import 
org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO;
 import org.apache.rocketmq.studio.ops.dashboard.DashboardProvider;
 import org.apache.rocketmq.studio.ops.dashboard.DashboardStatsVO;
 import org.apache.rocketmq.studio.common.util.SystemGroupFilter;
+import org.apache.rocketmq.studio.common.util.SystemTopicFilter;
 import org.apache.rocketmq.tools.admin.MQAdminExt;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Service;
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
index 5151c318..c61a174b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
@@ -321,7 +321,7 @@ public class RocketMQMessageProvider implements 
MessageProvider {
      */
     private long resolveMessageStoreTimestamp(DefaultMQAdminExt adminExt, 
String msgId) {
         try {
-            MessageExt messageExt = adminExt.viewMessage(msgId);
+            MessageExt messageExt = viewMessageByOffsetId(adminExt, msgId);
             if (messageExt != null) {
                 return messageExt.getStoreTimestamp();
             }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
index da3dea7b..13140550 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
@@ -34,6 +34,7 @@ import org.apache.rocketmq.tools.admin.MQAdminExt;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
 import org.apache.rocketmq.studio.common.util.SystemGroupFilter;
+import org.apache.rocketmq.studio.common.util.SystemTopicFilter;
 import org.apache.rocketmq.studio.common.domain.enums.TopicType;
 import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
 import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
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 62b9f8c0..16a867ea 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
@@ -16,7 +16,22 @@
  */
 package org.apache.rocketmq.studio.provider.tencent;
 
+import com.tencentcloudapi.trocket.v20230308.models.CreateTopicRequest;
+import com.tencentcloudapi.trocket.v20230308.models.DeleteTopicRequest;
+import com.tencentcloudapi.trocket.v20230308.models.DescribeTopicListRequest;
+import com.tencentcloudapi.trocket.v20230308.models.DescribeTopicListResponse;
+import com.tencentcloudapi.trocket.v20230308.models.DescribeTopicRequest;
+import com.tencentcloudapi.trocket.v20230308.models.DescribeTopicResponse;
+import com.tencentcloudapi.trocket.v20230308.models.ModifyTopicRequest;
+import com.tencentcloudapi.trocket.v20230308.models.SubscriptionData;
+import com.tencentcloudapi.trocket.v20230308.models.TopicItem;
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 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;
+import org.apache.rocketmq.studio.instance.InstanceVO;
 import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
 import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
 import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
@@ -26,16 +41,35 @@ import 
org.apache.rocketmq.studio.instance.topic.TopicConsumerVO;
 import org.apache.rocketmq.studio.instance.topic.TopicVO;
 import org.apache.rocketmq.studio.provider.InstanceProvider;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import lombok.RequiredArgsConstructor;
 
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 
 /**
- * Tencent Cloud TDMQ placeholder: package structure reserved, all operations 
unsupported.
+ * Tencent Cloud TDMQ RocketMQ 5.x topic operations backed by Trocket 
v20230308 OpenAPI.
+ *
+ * <p>Topic management is supported independently from the other 
instance-scoped operations. The
+ * remaining operations intentionally retain the provider's 
unsupported-operation behavior until
+ * their corresponding Tencent Cloud APIs are mapped to Studio's common 
models.</p>
  */
+@RequiredArgsConstructor
 @Component
 public class TencentInstanceProvider implements InstanceProvider {
 
-    private static final String NOT_IMPLEMENTED = "Tencent Cloud provider is 
not implemented yet";
+    static final int PAGE_SIZE = 100;
+    static final int MAX_PAGES = 100;
+    static final int CONSUMER_PAGE_SIZE = 100;
+    static final int DEFAULT_QUEUE_NUM = 8;
+    private static final String NOT_IMPLEMENTED = "Tencent Cloud operation is 
not implemented yet";
+
+    private final TencentClientFactory clientFactory;
+    private final InstanceRepository instanceRepository;
 
     @Override
     public InstanceVendor vendor() {
@@ -44,7 +78,7 @@ public class TencentInstanceProvider implements 
InstanceProvider {
 
     @Override
     public int countTopics(String instanceId) {
-        throw new UnsupportedOperationException(NOT_IMPLEMENTED);
+        return listTopics(instanceId, null, null, false).size();
     }
 
     @Override
@@ -54,27 +88,143 @@ public class TencentInstanceProvider implements 
InstanceProvider {
 
     @Override
     public List<TopicVO> listTopics(String instanceId, String type, String 
search) {
-        throw new UnsupportedOperationException(NOT_IMPLEMENTED);
+        return listTopics(instanceId, type, search, true);
+    }
+
+    private List<TopicVO> listTopics(String instanceId, String type, String 
search, boolean enrichTimes) {
+        Context context = resolve(instanceId);
+        List<TopicVO> topics = new ArrayList<>();
+        for (int page = 0; page < MAX_PAGES; page++) {
+            DescribeTopicListRequest request = new DescribeTopicListRequest();
+            request.setInstanceId(context.cloudInstanceId());
+            request.setOffset((long) page * PAGE_SIZE);
+            request.setLimit((long) PAGE_SIZE);
+            DescribeTopicListResponse response = 
clientFactory.call(context.credentialId(), context.regionId(),
+                    client -> client.DescribeTopicList(request));
+            TopicItem[] data = response == null ? null : response.getData();
+            if (data == null || data.length == 0) {
+                break;
+            }
+            for (TopicItem item : data) {
+                if (item == null) {
+                    continue;
+                }
+                TopicVO topic = toTopic(item, instanceId);
+                if (matchesType(type, topic) && matchesSearch(search, topic)) {
+                    if (enrichTimes) {
+                        enrichTopicTimes(context, topic);
+                    }
+                    topics.add(topic);
+                }
+            }
+            if (data.length < PAGE_SIZE) {
+                break;
+            }
+        }
+        return topics;
+    }
+
+    /**
+     * DescribeTopicList does not expose creation/update timestamps, so 
resolve them per-topic
+     * from DescribeTopic. Kept off the cheap count path to avoid N+1 calls 
for instance listings.
+     */
+    private void enrichTopicTimes(Context context, TopicVO topic) {
+        try {
+            DescribeTopicRequest request = new DescribeTopicRequest();
+            request.setInstanceId(context.cloudInstanceId());
+            request.setTopic(topic.getName());
+            DescribeTopicResponse response = 
clientFactory.call(context.credentialId(), context.regionId(),
+                    client -> client.DescribeTopic(request));
+            if (response == null) {
+                return;
+            }
+            topic.setCreatedAt(toLocalDateTime(response.getCreatedTime()));
+            topic.setUpdatedAt(toLocalDateTime(response.getLastUpdateTime()));
+        } catch (BusinessException ignored) {
+            // A single topic detail lookup failure should not fail the whole 
list.
+        }
+    }
+
+    private static LocalDateTime toLocalDateTime(Long epoch) {
+        if (epoch == null || epoch <= 0L) {
+            return null;
+        }
+        // Tencent Cloud returns millisecond timestamps; tolerate second 
precision as a fallback.
+        long epochMillis = epoch >= 10_000_000_000L ? epoch : epoch * 1000L;
+        return 
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDateTime();
     }
 
     @Override
     public TopicVO createTopic(String instanceId, TopicVO topic) {
-        throw new UnsupportedOperationException(NOT_IMPLEMENTED);
+        Context context = resolve(instanceId);
+        validateCreateTopic(topic);
+        CreateTopicRequest request = new CreateTopicRequest();
+        request.setInstanceId(context.cloudInstanceId());
+        request.setTopic(topic.getName());
+        request.setTopicType(topic.getType().name());
+        request.setQueueNum(queueNum(topic));
+        request.setRemark(topic.getRemark());
+        clientFactory.call(context.credentialId(), context.regionId(), client 
-> client.CreateTopic(request));
+        topic.setInstanceId(instanceId);
+        topic.setPerm(defaultPerm(topic.getPerm()));
+        topic.setWriteQueues(queueNum(topic).intValue());
+        topic.setReadQueues(queueNum(topic).intValue());
+        topic.setCreatedAt(LocalDateTime.now());
+        topic.setUpdatedAt(LocalDateTime.now());
+        return topic;
     }
 
     @Override
     public TopicVO updateTopic(String instanceId, TopicVO topic) {
-        throw new UnsupportedOperationException(NOT_IMPLEMENTED);
+        Context context = resolve(instanceId);
+        validateUpdateTopic(topic);
+        ModifyTopicRequest request = new ModifyTopicRequest();
+        request.setInstanceId(context.cloudInstanceId());
+        request.setTopic(topic.getName());
+        request.setRemark(topic.getRemark());
+        if (topic.getWriteQueues() > 0 || topic.getReadQueues() > 0) {
+            request.setQueueNum(queueNum(topic));
+        }
+        clientFactory.call(context.credentialId(), context.regionId(), client 
-> client.ModifyTopic(request));
+        topic.setInstanceId(instanceId);
+        topic.setPerm(defaultPerm(topic.getPerm()));
+        topic.setUpdatedAt(LocalDateTime.now());
+        return topic;
     }
 
     @Override
     public void deleteTopic(String instanceId, String topicName) {
-        throw new UnsupportedOperationException(NOT_IMPLEMENTED);
+        Context context = resolve(instanceId);
+        requireTopicName(topicName);
+        DeleteTopicRequest request = new DeleteTopicRequest();
+        request.setInstanceId(context.cloudInstanceId());
+        request.setTopic(topicName);
+        clientFactory.call(context.credentialId(), context.regionId(), client 
-> client.DeleteTopic(request));
     }
 
     @Override
     public List<TopicConsumerVO> getTopicConsumers(String instanceId, String 
topicName) {
-        throw new UnsupportedOperationException(NOT_IMPLEMENTED);
+        Context context = resolve(instanceId);
+        requireTopicName(topicName);
+        DescribeTopicRequest request = new DescribeTopicRequest();
+        request.setInstanceId(context.cloudInstanceId());
+        request.setTopic(topicName);
+        request.setOffset(0L);
+        request.setLimit((long) CONSUMER_PAGE_SIZE);
+        DescribeTopicResponse response = 
clientFactory.call(context.credentialId(), context.regionId(),
+                client -> client.DescribeTopic(request));
+        SubscriptionData[] data = response == null ? null : 
response.getSubscriptionData();
+        List<TopicConsumerVO> consumers = new ArrayList<>();
+        if (data == null) {
+            return consumers;
+        }
+        for (SubscriptionData subscription : data) {
+            if (subscription == null) {
+                continue;
+            }
+            consumers.add(toTopicConsumer(subscription));
+        }
+        return consumers;
     }
 
     @Override
@@ -117,4 +267,136 @@ public class TencentInstanceProvider implements 
InstanceProvider {
     public TraceRecordVO getMessageTrace(String instanceId, String msgId) {
         throw new UnsupportedOperationException(NOT_IMPLEMENTED);
     }
+
+    private Context resolve(String instanceId) {
+        if (!StringUtils.hasText(instanceId)) {
+            throw new BusinessException(400, "instanceId is required");
+        }
+        InstanceVO instance = instanceRepository.findById(instanceId)
+                .orElseThrow(() -> new BusinessException(404, "Instance not 
found: " + instanceId));
+        if (!StringUtils.hasText(instance.getCloudInstanceId()) || 
!StringUtils.hasText(instance.getRegionId())
+                || !StringUtils.hasText(instance.getCredentialId())) {
+            throw new BusinessException(400, "Instance " + instanceId + " is 
missing Tencent Cloud binding");
+        }
+        return new Context(instance.getCloudInstanceId(), 
instance.getRegionId(), instance.getCredentialId());
+    }
+
+    private static TopicVO toTopic(TopicItem item, String instanceId) {
+        TopicVO topic = new TopicVO();
+        topic.setName(item.getTopic());
+        topic.setInstanceId(instanceId);
+        topic.setType(toTopicType(item.getTopicType()));
+        topic.setWriteQueues(toInteger(item.getQueueNum()));
+        topic.setReadQueues(toInteger(item.getQueueNum()));
+        topic.setPerm(TopicPerm.RW);
+        topic.setRemark(item.getRemark());
+        topic.setNamespace(item.getNamespaceV4());
+        topic.setClusterId(item.getClusterIdV4());
+        return topic;
+    }
+
+    private static TopicConsumerVO toTopicConsumer(SubscriptionData 
subscription) {
+        String messageModel = subscription.getMessageModel();
+        if (!StringUtils.hasText(messageModel)) {
+            messageModel = subscription.getConsumeType();
+        }
+        return TopicConsumerVO.builder()
+                .group(subscription.getConsumerGroup())
+                .consumeType(toConsumeType(subscription.getConsumeType(), 
messageModel))
+                .messageModel(messageModel)
+                .diffTotal(subscription.getConsumerLag() == null ? 0L : 
subscription.getConsumerLag())
+                .build();
+    }
+
+    private static TopicType toTopicType(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return null;
+        }
+        try {
+            return TopicType.valueOf(raw.trim().toUpperCase(Locale.ROOT));
+        } catch (IllegalArgumentException ignored) {
+            return null;
+        }
+    }
+
+    private static ConsumeType toConsumeType(String consumeType, String 
messageModel) {
+        String value = StringUtils.hasText(consumeType) ? consumeType : 
messageModel;
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        if (value.toUpperCase(Locale.ROOT).contains("BROADCAST")) {
+            return ConsumeType.BROADCASTING;
+        }
+        if (value.toUpperCase(Locale.ROOT).contains("CLUSTER")) {
+            return ConsumeType.CLUSTERING;
+        }
+        return null;
+    }
+
+    private static boolean matchesType(String type, TopicVO topic) {
+        return !StringUtils.hasText(type)
+                || topic.getType() != null && 
topic.getType().name().equalsIgnoreCase(type.trim());
+    }
+
+    private static boolean matchesSearch(String search, TopicVO topic) {
+        if (!StringUtils.hasText(search)) {
+            return true;
+        }
+        String needle = search.trim().toLowerCase(Locale.ROOT);
+        return contains(topic.getName(), needle) || 
contains(topic.getRemark(), needle);
+    }
+
+    private static boolean contains(String value, String needle) {
+        return value != null && 
value.toLowerCase(Locale.ROOT).contains(needle);
+    }
+
+    private static void validateCreateTopic(TopicVO topic) {
+        validateTopicName(topic);
+        if (topic.getType() == null) {
+            throw new BusinessException(400, "Topic type is required");
+        }
+        if (topic.getType() == TopicType.LITE) {
+            throw new BusinessException(400, "Tencent Cloud RocketMQ does not 
support LiteTopic");
+        }
+        validateQueueNumber(topic);
+    }
+
+    private static void validateUpdateTopic(TopicVO topic) {
+        validateTopicName(topic);
+        validateQueueNumber(topic);
+    }
+
+    private static void validateTopicName(TopicVO topic) {
+        if (topic == null || !StringUtils.hasText(topic.getName())) {
+            throw new BusinessException(400, "Topic name is required");
+        }
+    }
+
+    private static void validateQueueNumber(TopicVO topic) {
+        if (topic.getWriteQueues() < 0 || topic.getReadQueues() < 0) {
+            throw new BusinessException(400, "Topic queue number must not be 
negative");
+        }
+    }
+
+    private static Long queueNum(TopicVO topic) {
+        int queueNum = topic.getWriteQueues() > 0 ? topic.getWriteQueues() : 
topic.getReadQueues();
+        return (long) (queueNum > 0 ? queueNum : DEFAULT_QUEUE_NUM);
+    }
+
+    private static TopicPerm defaultPerm(TopicPerm perm) {
+        return perm == null ? TopicPerm.RW : perm;
+    }
+
+    private static int toInteger(Long value) {
+        return value == null ? 0 : Math.toIntExact(value);
+    }
+
+    private static void requireTopicName(String topicName) {
+        if (!StringUtils.hasText(topicName)) {
+            throw new BusinessException(400, "Topic name is required");
+        }
+    }
+
+    private record Context(String cloudInstanceId, String regionId, String 
credentialId) {
+    }
 }
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 e3c084d3..a9ed54d7 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
@@ -16,54 +16,160 @@
  */
 package org.apache.rocketmq.studio.provider.tencent;
 
+import com.tencentcloudapi.trocket.v20230308.models.CreateTopicRequest;
+import com.tencentcloudapi.trocket.v20230308.models.DescribeTopicListResponse;
+import com.tencentcloudapi.trocket.v20230308.models.DescribeTopicResponse;
+import com.tencentcloudapi.trocket.v20230308.models.ModifyTopicRequest;
+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.InstanceVendor;
-import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.topic.TopicConsumerVO;
 import org.apache.rocketmq.studio.instance.topic.TopicVO;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
 
 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.anyString;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+@ExtendWith(MockitoExtension.class)
 class TencentInstanceProviderTest {
 
-    private final TencentInstanceProvider provider = new 
TencentInstanceProvider();
+    private static final String STUDIO_INSTANCE_ID = "inst-1";
+    private static final String CLOUD_INSTANCE_ID = "rmq-abc";
+    private static final String REGION = "ap-chengdu";
+    private static final String CREDENTIAL_ID = "cred-1";
+
+    @Mock
+    private TencentClientFactory clientFactory;
+
+    @Mock
+    private InstanceRepository instanceRepository;
+
+    @Mock
+    private TrocketClient client;
+
+    private TencentInstanceProvider provider;
+
+    @BeforeEach
+    void setUp() {
+        provider = new TencentInstanceProvider(clientFactory, 
instanceRepository);
+        
when(instanceRepository.findById(STUDIO_INSTANCE_ID)).thenReturn(Optional.of(InstanceVO.builder()
+                .name("tencent-prod")
+                .vendor(InstanceVendor.TENCENT)
+                .cloudInstanceId(CLOUD_INSTANCE_ID)
+                .regionId(REGION)
+                .credentialId(CREDENTIAL_ID)
+                .build()));
+        when(clientFactory.call(anyString(), anyString(), 
any())).thenAnswer(invocation -> {
+            TencentClientFactory.TencentCall<Object> action = 
invocation.getArgument(2);
+            return action.execute(client);
+        });
+    }
 
     @Test
-    void vendorShouldBeTencentTest() {
-        assertThat(provider.vendor()).isEqualTo(InstanceVendor.TENCENT);
+    void listTopicsShouldMapAndFilterAndEnrichTimesTest() throws Exception {
+        TopicItem normal = topicItem("orders", "NORMAL", 8L);
+        TopicItem fifo = topicItem("orders-fifo", "FIFO", 4L);
+        DescribeTopicListResponse response = new DescribeTopicListResponse();
+        response.setData(new TopicItem[]{normal, fifo});
+        when(client.DescribeTopicList(any())).thenReturn(response);
+        DescribeTopicResponse detail = new DescribeTopicResponse();
+        detail.setCreatedTime(1600000000000L);
+        detail.setLastUpdateTime(1600000100000L);
+        when(client.DescribeTopic(any())).thenReturn(detail);
+
+        List<TopicVO> topics = provider.listTopics(STUDIO_INSTANCE_ID, "FIFO", 
"fifo");
+
+        assertThat(topics).hasSize(1);
+        assertThat(topics.get(0).getName()).isEqualTo("orders-fifo");
+        assertThat(topics.get(0).getType()).isEqualTo(TopicType.FIFO);
+        assertThat(topics.get(0).getWriteQueues()).isEqualTo(4);
+        assertThat(topics.get(0).getReadQueues()).isEqualTo(4);
+        
assertThat(topics.get(0).getInstanceId()).isEqualTo(STUDIO_INSTANCE_ID);
+        assertThat(topics.get(0).getCreatedAt())
+                .isEqualTo(java.time.Instant.ofEpochMilli(1600000000000L)
+                        
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
+        assertThat(topics.get(0).getUpdatedAt())
+                .isEqualTo(java.time.Instant.ofEpochMilli(1600000100000L)
+                        
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
     }
 
     @Test
-    void allOperationsShouldThrowUnsupportedTest() {
-        assertThatThrownBy(() -> provider.countTopics("inst"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.countGroups("inst"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.listTopics("inst", null, null))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.createTopic("inst", new TopicVO()))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.updateTopic("inst", new TopicVO()))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.deleteTopic("inst", "topic"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.getTopicConsumers("inst", "topic"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.listConsumerGroups("inst", null))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.createConsumerGroup("inst", new 
ConsumerGroupVO()))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.deleteConsumerGroup("inst", "group"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.getGroupProgress("inst", "group"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.getGroupSubscriptions("inst", 
"group"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.resetOffset("inst", "group", 1L, 
"topic"))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.queryMessages("inst", "topic", null, 
null, null, null, null))
-                .isInstanceOf(UnsupportedOperationException.class);
-        assertThatThrownBy(() -> provider.getMessageTrace("inst", "msg"))
-                .isInstanceOf(UnsupportedOperationException.class);
+    void createTopicShouldCallTencentOpenApiTest() throws Exception {
+        when(client.CreateTopic(any())).thenReturn(null);
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setType(TopicType.NORMAL);
+        topic.setWriteQueues(12);
+        topic.setRemark("business orders");
+
+        TopicVO created = provider.createTopic(STUDIO_INSTANCE_ID, topic);
+
+        ArgumentCaptor<CreateTopicRequest> captor = 
ArgumentCaptor.forClass(CreateTopicRequest.class);
+        verify(client).CreateTopic(captor.capture());
+        
assertThat(captor.getValue().getInstanceId()).isEqualTo(CLOUD_INSTANCE_ID);
+        assertThat(captor.getValue().getTopic()).isEqualTo("orders");
+        assertThat(captor.getValue().getTopicType()).isEqualTo("NORMAL");
+        assertThat(captor.getValue().getQueueNum()).isEqualTo(12L);
+        assertThat(created.getInstanceId()).isEqualTo(STUDIO_INSTANCE_ID);
+    }
+
+    @Test
+    void updateAndDeleteTopicShouldCallTencentOpenApiTest() throws Exception {
+        when(client.ModifyTopic(any())).thenReturn(null);
+        when(client.DeleteTopic(any())).thenReturn(null);
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setType(TopicType.NORMAL);
+        topic.setRemark("updated");
+
+        provider.updateTopic(STUDIO_INSTANCE_ID, topic);
+        provider.deleteTopic(STUDIO_INSTANCE_ID, "orders");
+
+        ArgumentCaptor<ModifyTopicRequest> updateCaptor = 
ArgumentCaptor.forClass(ModifyTopicRequest.class);
+        verify(client).ModifyTopic(updateCaptor.capture());
+        
assertThat(updateCaptor.getValue().getInstanceId()).isEqualTo(CLOUD_INSTANCE_ID);
+        assertThat(updateCaptor.getValue().getTopic()).isEqualTo("orders");
+        assertThat(updateCaptor.getValue().getRemark()).isEqualTo("updated");
+    }
+
+    @Test
+    void getTopicConsumersShouldMapSubscriptionsTest() throws Exception {
+        SubscriptionData subscription = new SubscriptionData();
+        subscription.setConsumerGroup("GID_orders");
+        subscription.setConsumeType("CLUSTERING");
+        subscription.setMessageModel("CLUSTERING");
+        subscription.setConsumerLag(42L);
+        DescribeTopicResponse response = new DescribeTopicResponse();
+        response.setSubscriptionData(new SubscriptionData[]{subscription});
+        when(client.DescribeTopic(any())).thenReturn(response);
+
+        List<TopicConsumerVO> consumers = 
provider.getTopicConsumers(STUDIO_INSTANCE_ID, "orders");
+
+        assertThat(consumers).hasSize(1);
+        assertThat(consumers.get(0).getGroup()).isEqualTo("GID_orders");
+        assertThat(consumers.get(0).getDiffTotal()).isEqualTo(42L);
+    }
+
+    private static TopicItem topicItem(String name, String type, long 
queueNum) {
+        TopicItem item = new TopicItem();
+        item.setTopic(name);
+        item.setTopicType(type);
+        item.setQueueNum(queueNum);
+        return item;
     }
 }
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 7b574dd5..20411237 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -258,8 +258,10 @@ const parsePropsText = (text: string): Record<string, 
string> => {
   }
   return props;
 };
-const formatDateTime = (iso: string): string => {
+const formatDateTime = (iso?: string): string => {
+  if (!iso) return '-';
   const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return '-';
   const pad = (n: number) => String(n).padStart(2, '0');
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
 };
@@ -308,10 +310,14 @@ const TopicPage = () => {
   useEffect(() => {
     if (!selectedInstanceId) {
       topicRequestIdRef.current += 1;
-      setTopics([]);
-      setSelectedRowKeys([]);
-      setLoading(false);
-      return;
+      const resetTimer = window.setTimeout(() => {
+        setTopics([]);
+        setSelectedRowKeys([]);
+        setLoading(false);
+      }, 0);
+      return () => {
+        window.clearTimeout(resetTimer);
+      };
     }
     const requestId = ++topicRequestIdRef.current;
     const timer = window.setTimeout(() => {

Reply via email to