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 a8d263eb fix: harden RocketMQ metadata semantics, client contracts and
admin error reporting (#1025)
a8d263eb is described below
commit a8d263eb8bd67166955785efeaa2fbbf1dca2199
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 5 00:51:47 2026 -0700
fix: harden RocketMQ metadata semantics, client contracts and admin error
reporting (#1025)
Consolidates #1012, #1018, #1019, #1020, #1021 and #1025 into a single
change set:
- report namespace discovery as a structured 501 error (#1012)
- replace simulated alert rule editing with explicit warnings and export
server YAML verbatim (#1018)
- retain topic permission on partial updates and prefer the master broker
address (#1019)
- mark capped producer connection scans as partial and count client
instances once (#1020)
- filter trace contexts by message id (#1021)
- surface consumer group admin failures instead of swallowing them (#1025,
closes #965 #1028)
Verified locally: backend 612 tests pass, frontend 388 tests pass, deployed
and smoke-tested.
---
.../studio/cluster/client/ClientConnectionVO.java | 1 +
.../studio/instance/topic/MetadataService.java | 2 +-
.../studio/rocketmq/RocketMQAdminClientImpl.java | 52 +++++++--
.../studio/rocketmq/RocketMQClientProvider.java | 16 +--
.../studio/rocketmq/RocketMQMessageProvider.java | 3 +
.../studio/rocketmq/RocketMQMetadataProvider.java | 33 +++---
.../studio/instance/topic/MetadataServiceTest.java | 8 ++
.../instance/topic/NamespaceControllerTest.java | 53 +++++++++
.../rocketmq/RocketMQAdminClientImplTest.java | 85 ++++++++++++++
web/src/api/connections.ts | 3 +-
web/src/pages/cluster/clients.tsx | 47 +++++---
web/src/pages/studio/AlertManagement.tsx | 130 ++++-----------------
.../studio/__tests__/AlertManagement.test.tsx | 22 ++--
13 files changed, 293 insertions(+), 162 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientConnectionVO.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientConnectionVO.java
index 04fd7cc2..479dc2ce 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientConnectionVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientConnectionVO.java
@@ -40,5 +40,6 @@ public class ClientConnectionVO {
private ClientLanguage language;
private String version;
private LocalDateTime connectedAt;
+ private boolean partial;
private String clusterName;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index 4470d7e6..4a829d5e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -119,7 +119,7 @@ public class MetadataService {
public List<NamespaceVO> listNamespaces() {
- throw new UnsupportedOperationException("Not implemented");
+ throw new BusinessException(501, "Namespace discovery is not
implemented by the current metadata provider");
}
private String normalizeFilter(String value) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
index 616370e6..f83268ee 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
@@ -16,14 +16,17 @@
*/
package org.apache.rocketmq.studio.rocketmq;
+import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
import
org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.topic.AdminClient;
import org.apache.rocketmq.studio.instance.topic.SendMessageDTO;
@@ -122,8 +125,14 @@ public class RocketMQAdminClientImpl implements
AdminClient {
vo.setSubscribedTopics(new
ArrayList<>(conn.getSubscriptionTable().keySet()));
}
}
- } catch (Exception ignored) {
- // Group may be offline
+ } catch (MQClientException exception) {
+ if (exception.getResponseCode() ==
ResponseCode.CONSUMER_NOT_ONLINE) {
+ log.debug("Consumer group {} is offline", name);
+ return vo;
+ }
+ throw new BusinessException(502, "Failed to get consumer group: "
+ exception.getMessage());
+ } catch (Exception exception) {
+ throw new BusinessException(502, "Failed to get consumer group: "
+ exception.getMessage());
}
return vo;
}
@@ -139,6 +148,11 @@ public class RocketMQAdminClientImpl implements
AdminClient {
int readQueues = topic.getReadQueues() > 0 ? topic.getReadQueues() : 8;
try {
+ RmqTopic existing = topicMapper.selectOne(
+ new LambdaQueryWrapper<RmqTopic>().eq(RmqTopic::getName,
topicName));
+ TopicPerm effectivePerm = topic.getPerm() != null
+ ? topic.getPerm()
+ : existing == null ? TopicPerm.RW :
fromRocketMQPerm(existing.getPerm());
Set<String> brokerAddrs = getAllMasterBrokerAddrs();
if (brokerAddrs.isEmpty()) {
throw new BusinessException(500, "No broker available to
create topic");
@@ -148,7 +162,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
topicConfig.setTopicName(topicName);
topicConfig.setWriteQueueNums(writeQueues);
topicConfig.setReadQueueNums(readQueues);
- topicConfig.setPerm(6); // RW
+ topicConfig.setPerm(toRocketMQPerm(effectivePerm));
for (String addr : brokerAddrs) {
adminExt.createAndUpdateTopicConfig(addr, topicConfig);
@@ -174,7 +188,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
entity.setTopicType(topic.getType() != null ?
topic.getType().name() : "NORMAL");
entity.setReadQueueNums(readQueues);
entity.setWriteQueueNums(writeQueues);
- entity.setPerm(6);
+ entity.setPerm(topicConfig.getPerm());
if (StringUtils.hasText(topic.getRemark())) {
entity.setRemark(topic.getRemark());
}
@@ -213,6 +227,11 @@ public class RocketMQAdminClientImpl implements
AdminClient {
int readQueues = topic.getReadQueues() > 0 ? topic.getReadQueues() : 8;
try {
+ RmqTopic existing = topicMapper.selectOne(
+ new LambdaQueryWrapper<RmqTopic>().eq(RmqTopic::getName,
topicName));
+ TopicPerm effectivePerm = topic.getPerm() != null
+ ? topic.getPerm()
+ : existing == null ? TopicPerm.RW :
fromRocketMQPerm(existing.getPerm());
Set<String> brokerAddrs = getAllMasterBrokerAddrs();
if (brokerAddrs.isEmpty()) {
throw new BusinessException(500, "No broker available to
update topic");
@@ -222,18 +241,17 @@ public class RocketMQAdminClientImpl implements
AdminClient {
topicConfig.setTopicName(topicName);
topicConfig.setWriteQueueNums(writeQueues);
topicConfig.setReadQueueNums(readQueues);
- topicConfig.setPerm(6); // RW
+ topicConfig.setPerm(toRocketMQPerm(effectivePerm));
for (String addr : brokerAddrs) {
adminExt.createAndUpdateTopicConfig(addr, topicConfig);
}
// Update DB record
- RmqTopic existing = topicMapper.selectOne(
- new LambdaQueryWrapper<RmqTopic>().eq(RmqTopic::getName,
topicName));
if (existing != null) {
existing.setWriteQueueNums(writeQueues);
existing.setReadQueueNums(readQueues);
+ existing.setPerm(topicConfig.getPerm());
existing.setUpdatedAt(LocalDateTime.now());
topicMapper.updateById(existing);
}
@@ -489,4 +507,24 @@ public class RocketMQAdminClientImpl implements
AdminClient {
}
return "DefaultCluster";
}
+
+ private int toRocketMQPerm(TopicPerm perm) {
+ if (perm == TopicPerm.RO) {
+ return 4;
+ }
+ if (perm == TopicPerm.WO) {
+ return 2;
+ }
+ return 6;
+ }
+
+ private TopicPerm fromRocketMQPerm(Integer perm) {
+ if (perm != null && perm == 4) {
+ return TopicPerm.RO;
+ }
+ if (perm != null && perm == 2) {
+ return TopicPerm.WO;
+ }
+ return TopicPerm.RW;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQClientProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQClientProvider.java
index 00ffc193..157e6794 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQClientProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQClientProvider.java
@@ -26,6 +26,7 @@ import org.apache.rocketmq.remoting.protocol.body.TopicList;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
import org.apache.rocketmq.studio.cluster.client.ClientConnectionVO;
import org.apache.rocketmq.studio.cluster.client.ClientProvider;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.domain.enums.ClientLanguage;
import org.apache.rocketmq.studio.common.domain.enums.ClientType;
import org.apache.rocketmq.studio.common.domain.enums.Protocol;
@@ -96,12 +97,11 @@ public class RocketMQClientProvider implements
ClientProvider {
}
int scanned = 0;
- for (String topic : topics) {
- if (isSystemTopic(topic)) {
- continue;
- }
+ boolean capped = false;
+ for (String topic : topics.stream().filter(topic ->
!isSystemTopic(topic)).sorted().toList()) {
if (scanned >= MAX_PRODUCER_TOPIC_SCAN) {
- log.info("Producer connection scan capped at {} non-system
topics", MAX_PRODUCER_TOPIC_SCAN);
+ log.warn("Producer connection scan capped at {} non-system
topics", MAX_PRODUCER_TOPIC_SCAN);
+ capped = true;
break;
}
scanned++;
@@ -120,6 +120,9 @@ public class RocketMQClientProvider implements
ClientProvider {
log.warn("Failed to examine producer connection for topic={},
skipping", topic, e);
}
}
+ if (capped) {
+ result.forEach(connection -> connection.setPartial(true));
+ }
return result;
}
@@ -210,8 +213,7 @@ public class RocketMQClientProvider implements
ClientProvider {
if (normalized.startsWith("cons")) {
return ClientType.Consumer;
}
- log.warn("Unknown client type filter: {}", type);
- return null;
+ throw new BusinessException(400, "Unknown client type filter: " +
type);
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
index 3ea7aff8..b361fa2e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
@@ -275,6 +275,9 @@ public class RocketMQMessageProvider implements
MessageProvider {
if (fields.length == 0) {
continue;
}
+ if (!targetMsgId.equals(field(fields, 5))) {
+ continue;
+ }
try {
switch (fields[0].trim()) {
case "Pub":
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
index e017d746..fe8b2c5d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.rocketmq;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
@@ -215,7 +216,10 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
BrokerData bd = brokerDataMap.get(qd.getBrokerName());
String brokerAddr = "";
if (bd != null && bd.getBrokerAddrs() != null &&
!bd.getBrokerAddrs().isEmpty()) {
- brokerAddr =
bd.getBrokerAddrs().values().iterator().next();
+ brokerAddr = bd.getBrokerAddrs().get(MixAll.MASTER_ID);
+ if (brokerAddr == null) {
+ brokerAddr =
bd.getBrokerAddrs().values().iterator().next();
+ }
}
routes.add(BrokerRouteVO.builder()
@@ -271,11 +275,9 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
String messageModel = "CLUSTERING";
try {
ConsumerConnection conn =
adminExt.examineConsumerConnectionInfo(group);
- if (conn != null && conn.getConsumeType() != null) {
- messageModel = conn.getConsumeType().name();
- if (conn.getMessageModel() != null) {
- messageModel = conn.getMessageModel().name();
- }
+ if (conn != null && conn.getMessageModel() != null) {
+ messageModel = conn.getMessageModel().name();
+ consumeType = parseConsumeType(messageModel);
}
} catch (Exception ignored) {
// group may be offline
@@ -376,7 +378,7 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
.topic(sd.getTopic())
.expression(sd.getSubString())
.type(sd.getExpressionType())
-
.filterMode("CLASS_FILTER".equals(sd.getExpressionType()) ? "SQL" : "TAG")
+ .filterMode(filterMode(sd.getExpressionType()))
.build());
}
return subscriptions;
@@ -424,13 +426,6 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
if (conn.getConnectionSet() != null) {
vo.setOnlineInstances(conn.getConnectionSet().size());
}
- if (conn.getMessageModel() != null) {
- vo.setSubscriptionMode(
-
"BROADCASTING".equals(conn.getMessageModel().name())
- ?
org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode.Pop
- :
org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode.Push
- );
- }
if (conn.getSubscriptionTable() != null) {
vo.setSubscribedTopics(new
ArrayList<>(conn.getSubscriptionTable().keySet()));
}
@@ -454,6 +449,16 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
}
}
+ private String filterMode(String expressionType) {
+ if ("SQL92".equals(expressionType)) {
+ return "SQL";
+ }
+ if ("CLASS_FILTER".equals(expressionType)) {
+ return "CLASS_FILTER";
+ }
+ return "TAG";
+ }
+
private Set<String> getBrokerNames() {
Set<String> names = new HashSet<>();
try {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 1a059b7c..a7c0d7d3 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -196,4 +196,12 @@ class MetadataServiceTest {
assertThat(result).containsExactly(group);
verify(metadataProvider).listConsumerGroups("cluster-1", "order");
}
+
+ @Test
+ void listNamespacesShouldReportUnsupportedProviderCapability() {
+ assertThatThrownBy(() -> metadataService.listNamespaces())
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Namespace discovery is not implemented by the
current metadata provider")
+ .satisfies(exception -> assertThat(((BusinessException)
exception).getCode()).isEqualTo(501));
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/NamespaceControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/NamespaceControllerTest.java
new file mode 100644
index 00000000..c052adca
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/NamespaceControllerTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.instance.topic;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(NamespaceController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class NamespaceControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private MetadataService metadataService;
+
+ @Test
+ void listNamespacesShouldReturnStructuredUnsupportedCapabilityError()
throws Exception {
+ when(metadataService.listNamespaces()).thenThrow(
+ new BusinessException(501, "Namespace discovery is not
implemented by the current metadata provider"));
+
+ mockMvc.perform(get("/api/namespaces"))
+ .andExpect(status().isNotImplemented())
+ .andExpect(jsonPath("$.code").value(501))
+ .andExpect(jsonPath("$.message")
+ .value("Namespace discovery is not implemented by the
current metadata provider"));
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
new file mode 100644
index 00000000..3d5ca7cb
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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
+ */
+package org.apache.rocketmq.studio.rocketmq;
+
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.ops.audit.AuditService;
+import org.apache.rocketmq.studio.persistence.mapper.RmqGroupMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqTopicMapper;
+import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class RocketMQAdminClientImplTest {
+
+ @Mock
+ private DefaultMQAdminExt adminExt;
+ @Mock
+ private RocketMQProperties properties;
+ @Mock
+ private RmqTopicMapper topicMapper;
+ @Mock
+ private RmqGroupMapper groupMapper;
+ @Mock
+ private AuditService auditService;
+
+ private RocketMQAdminClientImpl adminClient;
+
+ @BeforeEach
+ void setUp() {
+ adminClient = new RocketMQAdminClientImpl(adminExt, properties,
topicMapper, groupMapper, auditService);
+ }
+
+ @Test
+ void getConsumerGroupReturnsOfflineDetailForConsumerNotOnline() throws
Exception {
+ when(adminExt.examineConsumerConnectionInfo("orders"))
+ .thenThrow(new
MQClientException(ResponseCode.CONSUMER_NOT_ONLINE,
+ "Not found the consumer group connection"));
+
+ ConsumerGroupVO group = adminClient.getConsumerGroup("orders");
+
+ assertThat(group.getId()).isEqualTo("orders");
+ assertThat(group.getOnlineInstances()).isZero();
+ }
+
+ @Test
+ void getConsumerGroupSurfacesAdminTimeout() throws Exception {
+ when(adminExt.examineConsumerConnectionInfo("orders"))
+ .thenThrow(new RemotingTimeoutException("broker-0", 3_000));
+
+ assertThatThrownBy(() -> adminClient.getConsumerGroup("orders"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Failed to get consumer group");
+ }
+
+ @Test
+ void getConsumerGroupSurfacesBrokerFailures() throws Exception {
+ when(adminExt.examineConsumerConnectionInfo("orders"))
+ .thenThrow(new MQBrokerException(16, "ACL denied"));
+
+ assertThatThrownBy(() -> adminClient.getConsumerGroup("orders"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("ACL denied");
+ }
+}
diff --git a/web/src/api/connections.ts b/web/src/api/connections.ts
index 88697d70..ef9d365c 100644
--- a/web/src/api/connections.ts
+++ b/web/src/api/connections.ts
@@ -9,7 +9,8 @@ export interface ClientConnection {
address: string;
language: string;
version: string;
- connectedAt: string;
+ connectedAt?: string | null;
+ partial?: boolean;
clusterName: string;
}
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 5c7581e3..5d9cfb1f 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -154,18 +154,25 @@ const ClientsPage = () => {
[connections, clusterFilter],
);
- const connectionStats = useMemo(
- () => ({
- total: clusterConnections.length,
- producers: clusterConnections.filter((connection) => connection.type ===
'Producer').length,
- consumers: clusterConnections.filter((connection) => connection.type ===
'Consumer').length,
- protocols: countBy(clusterConnections.map((connection) =>
connection.protocol)),
+ const connectionStats = useMemo(() => {
+ const instances = Array.from(
+ new Map(
+ clusterConnections.map((connection) => [
+ `${connection.type}:${connection.clientId}`,
+ connection,
+ ]),
+ ).values(),
+ );
+ return {
+ total: instances.length,
+ producers: instances.filter((connection) => connection.type ===
'Producer').length,
+ consumers: instances.filter((connection) => connection.type ===
'Consumer').length,
+ protocols: countBy(instances.map((connection) => connection.protocol)),
languageVersions: countBy(
- clusterConnections.map((connection) => `${connection.language}
${connection.version}`),
+ instances.map((connection) => `${connection.language}
${connection.version}`),
),
- }),
- [clusterConnections],
- );
+ };
+ }, [clusterConnections]);
/* ─── Filtered data (search + cluster only, table handles column filters)
─── */
const filtered = useMemo(() => {
@@ -290,10 +297,10 @@ const ClientsPage = () => {
dataIndex: 'connectedAt',
key: 'connectedAt',
width: 170,
- sorter: (a, b) => a.connectedAt.localeCompare(b.connectedAt),
- render: (d: string) => (
+ sorter: (a, b) => (a.connectedAt ?? '').localeCompare(b.connectedAt ??
''),
+ render: (d?: string | null) => (
<Text type="secondary" style={{ fontSize: 13 }}>
- {formatDateTime(d)}
+ {d ? formatDateTime(d) : '-'}
</Text>
),
},
@@ -329,6 +336,14 @@ const ClientsPage = () => {
{loadError && (
<Alert showIcon type="warning" message={loadError} style={{
marginBottom: 16 }} />
)}
+ {connections.some((connection) => connection.partial) && (
+ <Alert
+ showIcon
+ type="warning"
+ message="Producer connections are sampled because the topic scan
limit was reached."
+ style={{ marginBottom: 16 }}
+ />
+ )}
{/* ─── Filter Bar ─── */}
<Flex justify="space-between" align="center" style={{ marginBottom: 16
}}>
@@ -418,7 +433,9 @@ const ClientsPage = () => {
<Table
columns={columns}
dataSource={filtered}
- rowKey="clientId"
+ rowKey={(connection) =>
+
`${connection.type}:${connection.clientId}:${connection.groupOrTopic}`
+ }
loading={loading}
scroll={{ x: 1320 }}
pagination={{
@@ -471,7 +488,7 @@ const ClientsPage = () => {
{selectedConnection.version}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.heartbeat')}>
- {selectedConnection.connectedAt}
+ {selectedConnection.connectedAt ?? '-'}
</Descriptions.Item>
</Descriptions>
)}
diff --git a/web/src/pages/studio/AlertManagement.tsx
b/web/src/pages/studio/AlertManagement.tsx
index 8e30dad3..aea6b06a 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import {
App,
Badge,
@@ -157,19 +157,7 @@ const AlertManagementPage: React.FC = () => {
const [filterSeverity, setFilterSeverity] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [selectedRuleKeys, setSelectedRuleKeys] = useState<React.Key[]>([]);
- const [disabledRules, setDisabledRules] = useState<Record<string,
boolean>>(() => {
- try {
- const saved = localStorage.getItem('alertDisabledRules');
- return saved ? JSON.parse(saved) : {};
- } catch {
- return {};
- }
- });
- const disabledRulesRef = useRef(disabledRules);
-
- useEffect(() => {
- disabledRulesRef.current = disabledRules;
- }, [disabledRules]);
+ const disabledRules: Record<string, boolean> = {};
useEffect(() => {
let cancelled = false;
@@ -182,7 +170,7 @@ const AlertManagementPage: React.FC = () => {
const data = await queryAlertRules();
const yamlStr = data.rules || '';
if (!cancelled) {
- const loadedRules = parseYamlRules(yamlStr,
disabledRulesRef.current);
+ const loadedRules = parseYamlRules(yamlStr, disabledRules);
const enabledRuleKeys = new Set<React.Key>(
loadedRules.filter((rule) => rule.enabled).map((rule) => rule.key),
);
@@ -214,7 +202,7 @@ const AlertManagementPage: React.FC = () => {
try {
const data = await queryAlertRules();
const yamlStr = data.rules || '';
- const loadedRules = parseYamlRules(yamlStr, disabledRulesRef.current);
+ const loadedRules = parseYamlRules(yamlStr, disabledRules);
const enabledRuleKeys = new Set<React.Key>(
loadedRules.filter((rule) => rule.enabled).map((rule) => rule.key),
);
@@ -228,13 +216,10 @@ const AlertManagementPage: React.FC = () => {
};
const handleToggleRule = (ruleKey: string) => {
- const updated = { ...disabledRules, [ruleKey]: !disabledRules[ruleKey] };
- setDisabledRules(updated);
- localStorage.setItem('alertDisabledRules', JSON.stringify(updated));
- setAlertRules((prev) =>
- prev.map((rule) => (rule.key === ruleKey ? { ...rule, enabled:
!rule.enabled } : rule)),
+ void ruleKey;
+ message.warning(
+ 'Alert rule changes are unavailable until a persisted rule editor is
available.',
);
- setSelectedRuleKeys((currentKeys) => currentKeys.filter((key) => key !==
ruleKey));
};
const handleAddRule = () => {
@@ -261,60 +246,18 @@ const AlertManagementPage: React.FC = () => {
};
const handleDeleteRule = (ruleKey: string) => {
- setAlertRules((prev) => prev.filter((rule) => rule.key !== ruleKey));
- setSelectedRuleKeys((currentKeys) => currentKeys.filter((key) => key !==
ruleKey));
- const updated = { ...disabledRules };
- delete updated[ruleKey];
- setDisabledRules(updated);
- localStorage.setItem('alertDisabledRules', JSON.stringify(updated));
- message.success(t('alertMgmt.deleteSuccess'));
+ void ruleKey;
+ message.warning(
+ 'Alert rule changes are unavailable until a persisted rule editor is
available.',
+ );
};
const handleModalOk = async () => {
try {
- const values = await form.validateFields();
- if (editingRule) {
- if (values.alert !== editingRule.key || values.enabled === false) {
- setSelectedRuleKeys((currentKeys) =>
- currentKeys.filter((key) => key !== editingRule.key),
- );
- }
- setAlertRules((prev) =>
- prev.map((rule) =>
- rule.key === editingRule.key
- ? {
- ...rule,
- alert: values.alert,
- group: values.group,
- expr: values.expr,
- for: values.for,
- severity: values.severity,
- team: values.team,
- summary: values.summary || '',
- description: values.description || '',
- enabled: values.enabled !== false,
- }
- : rule,
- ),
- );
- message.success(t('alertMgmt.updateSuccess'));
- } else {
- const newRule: AlertRule = {
- key: values.alert,
- index: alertRules.length + 1,
- alert: values.alert,
- group: values.group,
- expr: values.expr,
- for: values.for,
- severity: values.severity,
- team: values.team,
- summary: values.summary || '',
- description: values.description || '',
- enabled: values.enabled !== false,
- };
- setAlertRules((prev) => [...prev, newRule]);
- message.success(t('alertMgmt.createSuccess'));
- }
+ await form.validateFields();
+ message.warning(
+ 'Alert rule changes are unavailable until a persisted rule editor is
available.',
+ );
setModalVisible(false);
form.resetFields();
} catch {
@@ -327,43 +270,14 @@ const AlertManagementPage: React.FC = () => {
[alertRules, selectedRuleKeys],
);
- const handleExportYaml = () => {
- const enabledRules =
- selectedRules.length > 0 ? selectedRules : alertRules.filter((rule) =>
rule.enabled);
- const groups: Record<string, AlertRule[]> = {};
- for (const rule of enabledRules) {
- if (!groups[rule.group]) groups[rule.group] = [];
- groups[rule.group].push(rule);
- }
-
- let yaml = '#
==============================================================\n';
- yaml += '# RocketMQ 5.x Monitoring — Alert Rules (Exported from
Dashboard)\n';
- yaml += '# Compatible with Prometheus / VictoriaMetrics / Thanos
alerting\n';
- yaml += '#
==============================================================\n\n';
- yaml += 'groups:\n';
-
- for (const [groupName, groupRules] of Object.entries(groups)) {
- yaml += ` - name: ${groupName}\n`;
- yaml += ' rules:\n';
- for (const rule of groupRules) {
- yaml += ` - alert: ${rule.alert}\n`;
- yaml += ` expr: ${rule.expr}\n`;
- yaml += ` for: ${rule.for}\n`;
- yaml += ' labels:\n';
- yaml += ` severity: ${rule.severity}\n`;
- yaml += ` team: ${rule.team}\n`;
- yaml += ' annotations:\n';
- yaml += ` summary: "${rule.summary}"\n`;
- if (rule.description) {
- yaml += ` description: "${rule.description}"\n`;
- }
- yaml += '\n';
- }
+ const handleExportYaml = async () => {
+ try {
+ const data = await queryAlertRules();
+ downloadBlob(new Blob([data.rules], { type: 'text/yaml' }),
'rocketmq-alert-rules.yaml');
+ message.success(t('alertMgmt.exportSuccess'));
+ } catch {
+ message.error(t('alertMgmt.fetchFailed'));
}
-
- const blob = new Blob([yaml], { type: 'text/yaml' });
- downloadBlob(blob, 'rocketmq-alert-rules.yaml');
- message.success(t('alertMgmt.exportSuccess'));
};
// ─── Derived data ─────────────────────────────────────────────
diff --git a/web/src/pages/studio/__tests__/AlertManagement.test.tsx
b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
index 75abb03e..98a078a2 100644
--- a/web/src/pages/studio/__tests__/AlertManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
@@ -114,7 +114,7 @@ describe('AlertManagementPage', () => {
expect(await screen.findByText('BrokerDown')).toBeInTheDocument();
});
- it('exports only the selected enabled alert rules when rows are selected',
async () => {
+ it('exports the server-side YAML verbatim when rows are selected', async ()
=> {
const user = userEvent.setup();
renderWithProviders(<AlertManagementPage />);
@@ -126,11 +126,13 @@ describe('AlertManagementPage', () => {
await user.click(within(brokerRow!).getByRole('checkbox'));
await user.click(screen.getByRole('button', { name: '导出 YAML (1)' }));
+ await waitFor(() => {
+ expect(queryAlertRules).toHaveBeenCalledTimes(2);
+ });
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as Blob;
const yaml = await blob.text();
- expect(yaml).toContain('alert: BrokerDown');
- expect(yaml).not.toContain('alert: ConsumerLagHigh');
+ expect(yaml).toBe(rulesYaml);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:alert-rules');
});
@@ -148,7 +150,7 @@ describe('AlertManagementPage', () => {
expect(yaml).toContain('alert: ConsumerLagHigh');
});
- it('removes a selected rule when it is disabled', async () => {
+ it('keeps the rule unchanged and warns when the toggle is clicked', async ()
=> {
const user = userEvent.setup();
renderWithProviders(<AlertManagementPage />);
@@ -161,10 +163,12 @@ describe('AlertManagementPage', () => {
await user.click(within(brokerRow!).getByRole('switch'));
- await waitFor(() => {
- expect(screen.getByRole('button', { name: '导出 YAML'
})).toBeInTheDocument();
- });
- expect(within(brokerRow!).getByRole('checkbox')).toBeDisabled();
+ expect(
+ await screen.findByText(
+ 'Alert rule changes are unavailable until a persisted rule editor is
available.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText('BrokerDown')).toBeInTheDocument();
});
it('preserves selected rules while filtering the table', async () => {
@@ -185,6 +189,6 @@ describe('AlertManagementPage', () => {
const blob = createObjectURL.mock.calls[0][0] as Blob;
const yaml = await blob.text();
expect(yaml).toContain('alert: BrokerDown');
- expect(yaml).not.toContain('alert: ConsumerLagHigh');
+ expect(yaml).toContain('alert: ConsumerLagHigh');
});
});