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 25c895f3 feat: add MQAdminExt-backed cluster provider and connection
test (#694)
25c895f3 is described below
commit 25c895f3f287e68c90200631301010e43c42134b
Author: zhaohai <[email protected]>
AuthorDate: Mon Aug 3 10:54:58 2026 +0800
feat: add MQAdminExt-backed cluster provider and connection test (#694)
---
server/pom.xml | 16 +++
.../cluster/broker/ClusterConnectionService.java | 65 ++++++++++
.../studio/cluster/broker/ClusterController.java | 6 +
.../studio/cluster/broker/ClusterProbeResult.java | 58 +++++++++
.../studio/cluster/broker/ClusterProviderStub.java | 87 -------------
.../studio/cluster/broker/MqAdminExtFactory.java | 141 +++++++++++++++++++++
.../studio/cluster/broker/MqAdminProperties.java | 38 ++++++
.../studio/cluster/broker/RealClusterProvider.java | 136 ++++++++++++++++++++
.../studio/cluster/broker/TestConnectionDTO.java | 37 ++++++
.../broker/ClusterConnectionServiceTest.java | 79 ++++++++++++
.../cluster/broker/ClusterControllerTest.java | 45 +++++++
.../cluster/broker/ClusterProviderStubTest.java | 48 -------
.../cluster/broker/MqAdminExtFactoryTest.java | 95 ++++++++++++++
.../cluster/broker/RealClusterProviderTest.java | 116 +++++++++++++++++
web/src/api/cluster.ts | 17 +++
web/src/i18n/translations.ts | 16 ++-
.../pages/cluster/__tests__/ClusterPage.test.tsx | 2 +
web/src/pages/cluster/index.tsx | 100 +++++++++++++--
web/src/services/clusterService.ts | 20 ++-
19 files changed, 972 insertions(+), 150 deletions(-)
diff --git a/server/pom.xml b/server/pom.xml
index 6450dcc0..1f2dccf3 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -19,6 +19,7 @@
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <rocketmq.version>5.3.3</rocketmq.version>
</properties>
<dependencies>
@@ -65,6 +66,21 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.rocketmq</groupId>
+ <artifactId>rocketmq-tools</artifactId>
+ <version>${rocketmq.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
</dependencies>
<build>
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterConnectionService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterConnectionService.java
new file mode 100644
index 00000000..8b0e9b6f
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterConnectionService.java
@@ -0,0 +1,65 @@
+/*
+ * 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.cluster.broker;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * Verifies live connectivity to a RocketMQ NameServer and summarises the
topology it reports.
+ *
+ * <p>Powers the "test connection" action in the cluster UI: it opens a real
admin connection
+ * through {@link RealClusterProvider}, so a failure surfaces as a {@code
BusinessException} with the
+ * underlying cause instead of a fabricated success.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ClusterConnectionService {
+
+ private final RealClusterProvider clusterProvider;
+
+ /**
+ * Probes the NameServer described by the command.
+ *
+ * @param command the connection request holding the NameServer address
+ * @return a populated {@link ClusterProbeResult} on success
+ */
+ public ClusterProbeResult testConnection(TestConnectionDTO command) {
+ String namesrvAddr = command.getNamesrvAddr().trim();
+ log.info("Testing connection to NameServer {}", namesrvAddr);
+ long start = System.currentTimeMillis();
+ ClusterVO cluster = clusterProvider.describeCluster(namesrvAddr);
+ long elapsed = System.currentTimeMillis() - start;
+
+ List<String> brokerNames = cluster.getBrokers() == null ? List.of()
+ :
cluster.getBrokers().stream().map(BrokerVO::getName).toList();
+
+ return ClusterProbeResult.builder()
+ .connected(true)
+ .namesrvAddr(namesrvAddr)
+ .clusterName(cluster.getName())
+ .brokerCount(brokerNames.size())
+ .brokerNames(brokerNames)
+ .elapsedMillis(elapsed)
+ .message("Connected to " + brokerNames.size() + " broker(s) in
" + elapsed + "ms")
+ .build();
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
index 1cd53c2c..3ba3161e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
@@ -37,12 +37,18 @@ import java.util.Map;
public class ClusterController {
private final ClusterService clusterService;
+ private final ClusterConnectionService clusterConnectionService;
@GetMapping
public Result<List<ClusterVO>> listClusters() {
return Result.ok(clusterService.listClusters());
}
+ @PostMapping("/test-connection")
+ public Result<ClusterProbeResult> testConnection(@Valid @RequestBody
TestConnectionDTO command) {
+ return Result.ok(clusterConnectionService.testConnection(command));
+ }
+
@GetMapping("/{id}")
public Result<ClusterVO> getCluster(@PathVariable String id) {
return Result.ok(clusterService.getCluster(id));
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterProbeResult.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterProbeResult.java
new file mode 100644
index 00000000..5e0dd4ad
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterProbeResult.java
@@ -0,0 +1,58 @@
+/*
+ * 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.cluster.broker;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Outcome of a live connectivity probe against a RocketMQ NameServer.
+ *
+ * <p>Returned by the {@code POST /api/clusters/test-connection} endpoint so
the UI can confirm a
+ * NameServer is reachable and preview the topology it exposes before the
cluster is registered.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ClusterProbeResult {
+
+ /** Whether the NameServer accepted the connection and returned cluster
info. */
+ private boolean connected;
+
+ /** The probed NameServer address list. */
+ private String namesrvAddr;
+
+ /** First cluster name reported by the NameServer, if any. */
+ private String clusterName;
+
+ /** Number of brokers registered with the cluster. */
+ private int brokerCount;
+
+ /** Names of the registered brokers. */
+ private List<String> brokerNames;
+
+ /** Round-trip time of the probe in milliseconds. */
+ private long elapsedMillis;
+
+ /** Human-readable outcome message. */
+ private String message;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterProviderStub.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterProviderStub.java
deleted file mode 100644
index 10e7271e..00000000
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterProviderStub.java
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * 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.cluster.broker;
-
-import org.apache.rocketmq.studio.cluster.nameserver.NameServerVO;
-import org.apache.rocketmq.studio.cluster.proxy.ProxyVO;
-
-import org.apache.rocketmq.studio.common.domain.enums.BrokerStatus;
-import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-
-@Component
-public class ClusterProviderStub implements ClusterProvider {
-
- @Override
- public List<ClusterVO> discoverClusters() {
- return List.of(buildSampleCluster("cluster-001"));
- }
-
- @Override
- public ClusterVO refreshClusterDetail(String clusterId) {
- return buildSampleCluster(clusterId);
- }
-
- private ClusterVO buildSampleCluster(String clusterId) {
- ClusterVO cluster = ClusterVO.builder()
- .name("rmq-cluster-01")
- .brokers(List.of(
- BrokerVO.builder()
- .name("broker-a")
- .addr("10.0.0.1:10911")
- .version("5.2.0")
- .status(BrokerStatus.running)
- .diskUsage(45.2)
- .tpsIn(1200)
- .tpsOut(800)
- .build(),
- BrokerVO.builder()
- .name("broker-b")
- .addr("10.0.0.2:10911")
- .version("5.2.0")
- .status(BrokerStatus.running)
- .diskUsage(38.7)
- .tpsIn(980)
- .tpsOut(750)
- .build()
- ))
- .proxies(List.of(
- ProxyVO.builder()
- .addr("10.0.0.10:8081")
- .status(ClusterStatus.healthy)
- .connections(156)
- .grpcPort(8081)
- .remotingPort(10911)
- .build()
- ))
- .nameServers(List.of(
- NameServerVO.builder()
- .addr("10.0.0.20:9876")
- .status(ClusterStatus.healthy)
- .build(),
- NameServerVO.builder()
- .addr("10.0.0.21:9876")
- .status(ClusterStatus.healthy)
- .build()
- ))
- .build();
- cluster.setId(clusterId);
- return cluster;
- }
-}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
new file mode 100644
index 00000000..7023df35
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
@@ -0,0 +1,141 @@
+/*
+ * 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.cluster.broker;
+
+import org.apache.rocketmq.remoting.RPCHook;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Central lifecycle owner for real {@link DefaultMQAdminExt} connections.
+ *
+ * <p>This is the single real-network entry point shared by every live
cluster/metadata provider.
+ * Admin clients are created lazily, started once and cached per NameServer
address so subsequent
+ * calls reuse the established connection. All cached clients are shut down on
context destruction.
+ *
+ * <p>The {@link RPCHook} parameter is reserved for the ACL / authentication
work (AUTH-01); it is
+ * currently always {@code null} but is threaded through so credentials can be
injected later
+ * without changing this contract.
+ */
+@Slf4j
+@Component
+public class MqAdminExtFactory {
+
+ /** Default admin RPC timeout in milliseconds. */
+ private static final long DEFAULT_TIMEOUT_MILLIS = 5000L;
+
+ private final Map<String, DefaultMQAdminExt> cache = new
ConcurrentHashMap<>();
+ private final AtomicInteger instanceCounter = new AtomicInteger();
+ private volatile boolean closed = false;
+
+ /**
+ * Runs an action against a started admin client bound to the given
NameServer address.
+ *
+ * @param namesrvAddr NameServer address list, e.g. {@code
host1:9876;host2:9876}
+ * @param rpcHook optional RPC hook for authentication, may be {@code
null}
+ * @param action the admin interaction to execute
+ * @param <T> result type
+ * @return the action result
+ * @throws BusinessException if the connection cannot be established or
the action fails
+ */
+ public <T> T execute(String namesrvAddr, RPCHook rpcHook, AdminAction<T>
action) {
+ if (namesrvAddr == null || namesrvAddr.isBlank()) {
+ throw new BusinessException(400, "NameServer address is required");
+ }
+ if (closed) {
+ throw new BusinessException(503, "Admin factory is shutting down");
+ }
+ DefaultMQAdminExt admin = cache.computeIfAbsent(namesrvAddr.trim(),
+ addr -> createAndStart(addr, rpcHook));
+ try {
+ return action.apply(admin);
+ } catch (BusinessException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ log.warn("Admin action failed against namesrv {}: {}",
namesrvAddr, ex.getMessage());
+ throw new BusinessException(502, "RocketMQ admin call failed: " +
rootMessage(ex));
+ }
+ }
+
+ private DefaultMQAdminExt createAndStart(String namesrvAddr, RPCHook
rpcHook) {
+ DefaultMQAdminExt admin = newAdmin(rpcHook);
+ admin.setNamesrvAddr(namesrvAddr);
+ admin.setInstanceName(buildInstanceName(namesrvAddr));
+ try {
+ admin.start();
+ log.info("Started RocketMQ admin client for namesrv {}",
namesrvAddr);
+ return admin;
+ } catch (Exception ex) {
+ safeShutdown(admin);
+ throw new BusinessException(502,
+ "Failed to connect NameServer " + namesrvAddr + ": " +
rootMessage(ex));
+ }
+ }
+
+ /**
+ * Creates a new (not-yet-started) admin client. Extracted so tests can
inject a stub without a
+ * live cluster.
+ */
+ protected DefaultMQAdminExt newAdmin(RPCHook rpcHook) {
+ return new DefaultMQAdminExt(rpcHook, DEFAULT_TIMEOUT_MILLIS);
+ }
+
+ private String buildInstanceName(String namesrvAddr) {
+ return "rmq-studio-" + Integer.toHexString(namesrvAddr.hashCode())
+ + "-" + instanceCounter.incrementAndGet();
+ }
+
+ private void safeShutdown(MQAdminExt admin) {
+ try {
+ admin.shutdown();
+ } catch (Exception ex) {
+ log.debug("Ignoring admin shutdown error: {}", ex.getMessage());
+ }
+ }
+
+ private String rootMessage(Throwable ex) {
+ Throwable cause = ex;
+ while (cause.getCause() != null && cause.getCause() != cause) {
+ cause = cause.getCause();
+ }
+ String message = cause.getMessage();
+ return message == null ? cause.getClass().getSimpleName() : message;
+ }
+
+ @PreDestroy
+ public void shutdown() {
+ closed = true;
+ cache.values().forEach(this::safeShutdown);
+ cache.clear();
+ log.info("Shut down all RocketMQ admin clients");
+ }
+
+ /** Callback executed against a live admin client. */
+ @FunctionalInterface
+ public interface AdminAction<T> {
+ T apply(MQAdminExt admin) throws Exception;
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminProperties.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminProperties.java
new file mode 100644
index 00000000..fe5ece97
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminProperties.java
@@ -0,0 +1,38 @@
+/*
+ * 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.cluster.broker;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Configuration for the default RocketMQ cluster the studio connects to.
+ *
+ * <p>When {@code namesrvAddr} is left blank the {@link RealClusterProvider}
performs no discovery
+ * and callers rely on the interactive connection-test endpoint instead.
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "studio.cluster.admin")
+public class MqAdminProperties {
+
+ /** NameServer address list, e.g. {@code host1:9876;host2:9876}; blank
disables discovery. */
+ private String namesrvAddr;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
new file mode 100644
index 00000000..542b9b1a
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
@@ -0,0 +1,136 @@
+/*
+ * 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.cluster.broker;
+
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.studio.cluster.nameserver.NameServerVO;
+import org.apache.rocketmq.studio.common.domain.enums.BrokerStatus;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * {@link ClusterProvider} backed by a live NameServer connection.
+ *
+ * <p>Replaces the former hard-coded stub: cluster topology is read on demand
from the RocketMQ
+ * NameServer through {@link MqAdminExtFactory#examineBrokerClusterInfo}.
Discovery honours the
+ * {@link MqAdminProperties#getNamesrvAddr() configured NameServer}; when none
is configured no
+ * clusters are returned and callers fall back to the interactive
connection-test flow.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RealClusterProvider implements ClusterProvider {
+
+ private final MqAdminExtFactory adminFactory;
+ private final MqAdminProperties properties;
+
+ @Override
+ public List<ClusterVO> discoverClusters() {
+ String namesrvAddr = properties.getNamesrvAddr();
+ if (namesrvAddr == null || namesrvAddr.isBlank()) {
+ log.info("No NameServer configured; skipping cluster discovery");
+ return List.of();
+ }
+ return List.of(describeCluster(namesrvAddr));
+ }
+
+ @Override
+ public ClusterVO refreshClusterDetail(String clusterId) {
+ String namesrvAddr = properties.getNamesrvAddr();
+ if (namesrvAddr == null || namesrvAddr.isBlank()) {
+ throw new BusinessException(400, "No NameServer configured for
cluster " + clusterId);
+ }
+ return describeCluster(namesrvAddr);
+ }
+
+ /**
+ * Connects to the given NameServer and maps its live topology into a
{@link ClusterVO}.
+ *
+ * @param namesrvAddr NameServer address list, e.g. {@code
host1:9876;host2:9876}
+ * @return the cluster snapshot reported by the NameServer
+ * @throws BusinessException if the NameServer is unreachable or returns
an error
+ */
+ public ClusterVO describeCluster(String namesrvAddr) {
+ return adminFactory.execute(namesrvAddr, null,
+ admin -> toClusterVO(namesrvAddr,
admin.examineBrokerClusterInfo()));
+ }
+
+ private ClusterVO toClusterVO(String namesrvAddr, ClusterInfo clusterInfo)
{
+ Map<String, BrokerData> brokerAddrTable =
+ clusterInfo.getBrokerAddrTable() == null ? Map.of() :
clusterInfo.getBrokerAddrTable();
+ Map<String, Set<String>> clusterAddrTable =
+ clusterInfo.getClusterAddrTable() == null ? Map.of() :
clusterInfo.getClusterAddrTable();
+
+ List<BrokerVO> brokers = brokerAddrTable.values().stream()
+ .map(this::toBrokerVO)
+ .sorted(Comparator.comparing(BrokerVO::getName,
+ Comparator.nullsLast(Comparator.naturalOrder())))
+ .toList();
+
+ String clusterName = clusterAddrTable.keySet().stream()
+ .sorted()
+ .findFirst()
+ .orElse("DefaultCluster");
+
+ List<NameServerVO> nameServers =
Arrays.stream(namesrvAddr.split("[;,]"))
+ .map(String::trim)
+ .filter(addr -> !addr.isEmpty())
+ .map(addr ->
NameServerVO.builder().addr(addr).status(ClusterStatus.healthy).build())
+ .toList();
+
+ ClusterVO cluster = ClusterVO.builder()
+ .name(clusterName)
+ .nsClusterName(clusterName)
+ .type(ClusterType.V4_DIRECT)
+ .endpoint(namesrvAddr)
+ .status(ClusterStatus.healthy)
+ .brokers(brokers)
+ .proxies(List.of())
+ .nameServers(nameServers)
+ .build();
+ cluster.setId(clusterName);
+ return cluster;
+ }
+
+ private BrokerVO toBrokerVO(BrokerData data) {
+ String addr = null;
+ if (data.getBrokerAddrs() != null) {
+ addr = data.getBrokerAddrs().get(MixAll.MASTER_ID);
+ if (addr == null) {
+ addr =
data.getBrokerAddrs().values().stream().findFirst().orElse(null);
+ }
+ }
+ return BrokerVO.builder()
+ .name(data.getBrokerName())
+ .addr(addr)
+ .status(BrokerStatus.running)
+ .build();
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/TestConnectionDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/TestConnectionDTO.java
new file mode 100644
index 00000000..fd1c43e1
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/TestConnectionDTO.java
@@ -0,0 +1,37 @@
+/*
+ * 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.cluster.broker;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Request payload for probing a live RocketMQ cluster before registering it.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TestConnectionDTO {
+
+ /** NameServer address list, e.g. {@code host1:9876;host2:9876}. */
+ @NotBlank(message = "namesrvAddr is required")
+ private String namesrvAddr;
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterConnectionServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterConnectionServiceTest.java
new file mode 100644
index 00000000..e7c4986c
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterConnectionServiceTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.cluster.broker;
+
+import org.apache.rocketmq.studio.common.domain.enums.BrokerStatus;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ClusterConnectionServiceTest {
+
+ @Mock
+ private RealClusterProvider clusterProvider;
+
+ @InjectMocks
+ private ClusterConnectionService service;
+
+ private ClusterVO clusterWith(String... brokerNames) {
+ List<BrokerVO> brokers = java.util.Arrays.stream(brokerNames)
+ .map(name ->
BrokerVO.builder().name(name).status(BrokerStatus.running).build())
+ .toList();
+ return ClusterVO.builder()
+ .name("DefaultCluster")
+ .brokers(brokers)
+ .build();
+ }
+
+ @Test
+ void testConnectionShouldSummariseTopology() {
+ when(clusterProvider.describeCluster(eq("10.0.0.1:9876")))
+ .thenReturn(clusterWith("broker-a", "broker-b"));
+
+ ClusterProbeResult result = service.testConnection(
+ TestConnectionDTO.builder().namesrvAddr(" 10.0.0.1:9876
").build());
+
+ assertThat(result.isConnected()).isTrue();
+ assertThat(result.getNamesrvAddr()).isEqualTo("10.0.0.1:9876");
+ assertThat(result.getClusterName()).isEqualTo("DefaultCluster");
+ assertThat(result.getBrokerCount()).isEqualTo(2);
+ assertThat(result.getBrokerNames()).containsExactly("broker-a",
"broker-b");
+ assertThat(result.getElapsedMillis()).isGreaterThanOrEqualTo(0L);
+ }
+
+ @Test
+ void testConnectionShouldPropagateProviderFailure() {
+ when(clusterProvider.describeCluster(eq("10.0.0.9:9876")))
+ .thenThrow(new BusinessException(502, "Failed to connect
NameServer"));
+
+ assertThatThrownBy(() -> service.testConnection(
+
TestConnectionDTO.builder().namesrvAddr("10.0.0.9:9876").build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Failed to connect NameServer");
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
index 1c23fcdb..cb33bbcd 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
@@ -61,6 +61,51 @@ class ClusterControllerTest {
@MockBean
private ClusterService clusterService;
+ @MockBean
+ private ClusterConnectionService clusterConnectionService;
+
+ @Test
+ void testConnectionShouldReturnProbeResult() throws Exception {
+ ClusterProbeResult probe = ClusterProbeResult.builder()
+ .connected(true)
+ .namesrvAddr("10.0.0.1:9876")
+ .clusterName("DefaultCluster")
+ .brokerCount(2)
+ .brokerNames(Arrays.asList("broker-a", "broker-b"))
+ .elapsedMillis(12L)
+ .message("Connected to 2 broker(s) in 12ms")
+ .build();
+
when(clusterConnectionService.testConnection(any(TestConnectionDTO.class))).thenReturn(probe);
+
+ ObjectNode command =
objectMapper.createObjectNode().put("namesrvAddr", "10.0.0.1:9876");
+
+ mockMvc.perform(post("/api/clusters/test-connection")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(command)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.connected").value(true))
+
.andExpect(jsonPath("$.data.clusterName").value("DefaultCluster"))
+ .andExpect(jsonPath("$.data.brokerCount").value(2))
+ .andExpect(jsonPath("$.data.brokerNames.length()").value(2));
+
+
verify(clusterConnectionService).testConnection(any(TestConnectionDTO.class));
+ }
+
+ @Test
+ void testConnectionShouldRejectBlankNamesrvAddr() throws Exception {
+ ObjectNode command =
objectMapper.createObjectNode().put("namesrvAddr", " ");
+
+ mockMvc.perform(post("/api/clusters/test-connection")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(command)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("namesrvAddr is
required"));
+
+ verifyNoInteractions(clusterConnectionService);
+ }
+
@Test
void listClustersShouldReturnAllClusters() throws Exception {
ClusterVO cluster1 = buildCluster("cluster-1", "production-cluster",
ClusterStatus.healthy);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterProviderStubTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterProviderStubTest.java
deleted file mode 100644
index a9882330..00000000
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterProviderStubTest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * 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.cluster.broker;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-class ClusterProviderStubTest {
-
- private final ClusterProviderStub provider = new ClusterProviderStub();
-
- @Test
- void discoverClustersShouldReturnStableClusterId() {
- List<ClusterVO> clusters = provider.discoverClusters();
-
- assertThat(clusters).hasSize(1);
- assertThat(clusters.get(0).getId()).isEqualTo("cluster-001");
- assertThat(clusters.get(0).getBrokers()).isNotEmpty();
- assertThat(clusters.get(0).getProxies()).isNotEmpty();
- assertThat(clusters.get(0).getNameServers()).isNotEmpty();
- }
-
- @Test
- void refreshClusterDetailShouldPreserveRequestedClusterId() {
- ClusterVO detail = provider.refreshClusterDetail("cluster-prod");
-
- assertThat(detail.getId()).isEqualTo("cluster-prod");
- assertThat(detail.getName()).isEqualTo("rmq-cluster-01");
- assertThat(detail.getBrokers()).hasSize(2);
- }
-}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
new file mode 100644
index 00000000..89151866
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.cluster.broker;
+
+import org.apache.rocketmq.remoting.RPCHook;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+class MqAdminExtFactoryTest {
+
+ private static final class RecordingFactory extends MqAdminExtFactory {
+ private final DefaultMQAdminExt admin;
+ private final AtomicInteger created = new AtomicInteger();
+
+ private RecordingFactory(DefaultMQAdminExt admin) {
+ this.admin = admin;
+ }
+
+ @Override
+ protected DefaultMQAdminExt newAdmin(RPCHook rpcHook) {
+ created.incrementAndGet();
+ return admin;
+ }
+ }
+
+ @Test
+ void executeShouldRejectBlankNamesrvAddr() {
+ MqAdminExtFactory factory = new MqAdminExtFactory();
+
+ assertThatThrownBy(() -> factory.execute(" ", null, admin ->
"unused"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("NameServer address is required");
+ }
+
+ @Test
+ void executeShouldStartClientOnceAndCacheIt() throws Exception {
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+ RecordingFactory factory = new RecordingFactory(admin);
+
+ String first = factory.execute("10.0.0.1:9876", null, a -> "first");
+ String second = factory.execute("10.0.0.1:9876", null, a -> "second");
+
+ assertThat(first).isEqualTo("first");
+ assertThat(second).isEqualTo("second");
+ assertThat(factory.created.get()).isEqualTo(1);
+ verify(admin, times(1)).start();
+ }
+
+ @Test
+ void executeShouldWrapConnectionFailure() throws Exception {
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+ doThrow(new RuntimeException("connection
refused")).when(admin).start();
+ RecordingFactory factory = new RecordingFactory(admin);
+
+ assertThatThrownBy(() -> factory.execute("10.0.0.9:9876", null, a ->
"unused"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("connection refused");
+ verify(admin).shutdown();
+ }
+
+ @Test
+ void executeShouldRejectCallsAfterShutdown() {
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+ RecordingFactory factory = new RecordingFactory(admin);
+ factory.shutdown();
+
+ assertThatThrownBy(() -> factory.execute("10.0.0.1:9876", null, a ->
"unused"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("shutting down");
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
new file mode 100644
index 00000000..2ed9ddab
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.cluster.broker;
+
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.studio.common.domain.enums.BrokerStatus;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+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 java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class RealClusterProviderTest {
+
+ @Mock
+ private MqAdminExtFactory adminFactory;
+
+ private final MqAdminProperties properties = new MqAdminProperties();
+
+ private RealClusterProvider provider;
+
+ @BeforeEach
+ void setUp() {
+ provider = new RealClusterProvider(adminFactory, properties);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void stubClusterInfo(String namesrvAddr, ClusterInfo info) throws
Exception {
+ MQAdminExt admin = org.mockito.Mockito.mock(MQAdminExt.class);
+ when(admin.examineBrokerClusterInfo()).thenReturn(info);
+ when(adminFactory.execute(eq(namesrvAddr), isNull(),
any())).thenAnswer(invocation -> {
+ MqAdminExtFactory.AdminAction<Object> action =
invocation.getArgument(2);
+ return action.apply(admin);
+ });
+ }
+
+ private ClusterInfo sampleClusterInfo() {
+ ClusterInfo info = new ClusterInfo();
+ HashMap<Long, String> addrs = new HashMap<>();
+ addrs.put(0L, "10.0.0.11:10911");
+ addrs.put(1L, "10.0.0.12:10911");
+ HashMap<String, BrokerData> brokerAddrTable = new HashMap<>();
+ brokerAddrTable.put("broker-b",
+ new BrokerData("DefaultCluster", "broker-b", new
HashMap<>(addrs)));
+ brokerAddrTable.put("broker-a",
+ new BrokerData("DefaultCluster", "broker-a", new
HashMap<>(addrs)));
+ info.setBrokerAddrTable(brokerAddrTable);
+ HashMap<String, Set<String>> clusterAddrTable = new HashMap<>();
+ clusterAddrTable.put("DefaultCluster", Set.of("broker-a", "broker-b"));
+ info.setClusterAddrTable(clusterAddrTable);
+ return info;
+ }
+
+ @Test
+ void describeClusterShouldMapLiveTopology() throws Exception {
+ stubClusterInfo("10.0.0.1:9876;10.0.0.2:9876", sampleClusterInfo());
+
+ ClusterVO cluster =
provider.describeCluster("10.0.0.1:9876;10.0.0.2:9876");
+
+ assertThat(cluster.getName()).isEqualTo("DefaultCluster");
+ assertThat(cluster.getType()).isEqualTo(ClusterType.V4_DIRECT);
+
assertThat(cluster.getEndpoint()).isEqualTo("10.0.0.1:9876;10.0.0.2:9876");
+ assertThat(cluster.getNameServers()).extracting("addr")
+ .containsExactly("10.0.0.1:9876", "10.0.0.2:9876");
+ assertThat(cluster.getBrokers()).hasSize(2);
+
assertThat(cluster.getBrokers().get(0).getName()).isEqualTo("broker-a");
+
assertThat(cluster.getBrokers().get(0).getAddr()).isEqualTo("10.0.0.11:10911");
+
assertThat(cluster.getBrokers().get(0).getStatus()).isEqualTo(BrokerStatus.running);
+ }
+
+ @Test
+ void discoverClustersShouldReturnEmptyWhenNoNamesrvConfigured() {
+ properties.setNamesrvAddr(" ");
+
+ assertThat(provider.discoverClusters()).isEmpty();
+ }
+
+ @Test
+ void discoverClustersShouldUseConfiguredNamesrv() throws Exception {
+ properties.setNamesrvAddr("10.0.0.1:9876");
+ stubClusterInfo("10.0.0.1:9876", sampleClusterInfo());
+
+ List<ClusterVO> clusters = provider.discoverClusters();
+
+ assertThat(clusters).hasSize(1);
+ assertThat(clusters.get(0).getBrokers()).hasSize(2);
+ }
+}
diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts
index 5ea7912d..cb8f4ea4 100644
--- a/web/src/api/cluster.ts
+++ b/web/src/api/cluster.ts
@@ -73,6 +73,16 @@ export interface ClusterConfig {
deleteWhen: string;
}
+export interface ClusterProbeResult {
+ connected: boolean;
+ namesrvAddr: string;
+ clusterName: string;
+ brokerCount: number;
+ brokerNames: string[];
+ elapsedMillis: number;
+ message: string;
+}
+
export interface K8sCertInfo {
id: string;
name: string;
@@ -93,6 +103,13 @@ export async function listClusters() {
return res.data.data;
}
+export async function testClusterConnection(namesrvAddr: string) {
+ const res = await client.post<{ data: ClusterProbeResult
}>('/clusters/test-connection', {
+ namesrvAddr,
+ });
+ return res.data.data;
+}
+
export async function getCluster(id: string) {
const res = await client.get<{ data: ClusterInfo
}>(`/clusters/${pathSegment(id)}`);
return res.data.data;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 321ba2e7..389016cb 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -501,10 +501,18 @@ const translations: Record<string, Record<Lang, string>>
= {
'cluster.totalTps': { zh: '总 TPS', en: 'Total TPS' },
'cluster.avgTps': { zh: '平均 TPS', en: 'Avg TPS' },
'cluster.consumerGroupCount': { zh: '消费者组数', en: 'Consumer Group Count' },
- 'cluster.createClusterWip': {
- zh: '新建集群功能开发中',
- en: 'Create cluster feature is in development',
- },
+ 'cluster.testConnection': { zh: '测试连接', en: 'Test Connection' },
+ 'cluster.testConnectionTitle': { zh: '测试集群连接', en: 'Test Cluster Connection'
},
+ 'cluster.testConnectionDesc': {
+ zh: '输入 NameServer 地址以验证连通性并预览集群拓扑',
+ en: 'Enter a NameServer address to verify connectivity and preview the
cluster topology',
+ },
+ 'cluster.testConnectionSuccess': { zh: '连接成功', en: 'Connection successful' },
+ 'cluster.testConnectionFailed': { zh: '连接失败', en: 'Connection failed' },
+ 'cluster.probeClusterName': { zh: '集群名称', en: 'Cluster Name' },
+ 'cluster.probeBrokerCount': { zh: 'Broker 数量', en: 'Broker Count' },
+ 'cluster.probeBrokers': { zh: 'Broker 列表', en: 'Brokers' },
+ 'cluster.probeElapsed': { zh: '耗时 (ms)', en: 'Elapsed (ms)' },
'cluster.configTitle': { zh: '配置 - {name}', en: 'Config - {name}' },
'cluster.configUpdated': { zh: '配置已更新', en: 'Configuration updated' },
'cluster.flushDiskType': { zh: '刷盘方式', en: 'Flush Disk Type' },
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 8ead1bdb..7d3aa093 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -27,6 +27,7 @@ const clusterServiceMocks = vi.hoisted(() => ({
createNameServer: vi.fn(),
listClusters: vi.fn(),
restartProxy: vi.fn(),
+ testClusterConnection: vi.fn(),
updateClusterConfig: vi.fn(),
updateNameServer: vi.fn(),
}));
@@ -134,6 +135,7 @@ describe('Cluster page', () => {
clusterServiceMocks.createNameServer.mockReset().mockResolvedValue(undefined);
clusterServiceMocks.listClusters.mockReset().mockResolvedValue([buildCluster()]);
clusterServiceMocks.restartProxy.mockReset().mockResolvedValue(undefined);
+ clusterServiceMocks.testClusterConnection.mockReset();
clusterServiceMocks.updateClusterConfig.mockReset().mockResolvedValue(undefined);
clusterServiceMocks.updateNameServer.mockReset().mockResolvedValue(undefined);
});
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index d2d53153..4d4e0a8e 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -53,11 +53,13 @@ import type {
NameServerInfo,
ClusterConfig,
ClusterInfo,
+ ClusterProbeResult,
} from '../../api/cluster';
import {
createNameServer,
listClusters,
restartProxy,
+ testClusterConnection,
updateClusterConfig,
updateNameServer,
} from '../../services/clusterService';
@@ -89,6 +91,44 @@ const ClusterPage = () => {
const [nsForm] = Form.useForm();
const [configForm] = Form.useForm();
+ // ─── Connection test ──────────────────────────────────────────────────────
+ const [connectModalOpen, setConnectModalOpen] = useState(false);
+ const [connectTesting, setConnectTesting] = useState(false);
+ const [probeResult, setProbeResult] = useState<ClusterProbeResult |
null>(null);
+ const [connectForm] = Form.useForm();
+
+ const openConnectModal = useCallback(() => {
+ setProbeResult(null);
+ setConnectModalOpen(true);
+ }, []);
+
+ const closeConnectModal = useCallback(() => {
+ setConnectModalOpen(false);
+ setConnectTesting(false);
+ setProbeResult(null);
+ connectForm.resetFields();
+ }, [connectForm]);
+
+ const handleTestConnection = useCallback(async () => {
+ let namesrvAddr: string;
+ try {
+ ({ namesrvAddr } = await connectForm.validateFields());
+ } catch {
+ return;
+ }
+ setConnectTesting(true);
+ setProbeResult(null);
+ try {
+ const result = await testClusterConnection(namesrvAddr);
+ setProbeResult(result);
+ message.success(t('cluster.testConnectionSuccess'));
+ } catch {
+ message.error(t('cluster.testConnectionFailed'));
+ } finally {
+ setConnectTesting(false);
+ }
+ }, [connectForm, t]);
+
// ─── Cluster refresh coordinator ──────────────────────────────────────────
const [autoRefresh, setAutoRefresh] = useState(true);
const [refreshFailed, setRefreshFailed] = useState(false);
@@ -451,11 +491,7 @@ const ClusterPage = () => {
options={nsClusterOptions}
/>
</Space>
- <Button
- type="primary"
- icon={<PlusOutlined />}
- onClick={() => message.info(t('cluster.createClusterWip'))}
- >
+ <Button type="primary" icon={<PlusOutlined />}
onClick={openConnectModal}>
{t('cluster.createCluster')}
</Button>
</Flex>
@@ -850,11 +886,7 @@ const ClusterPage = () => {
style={{ width: 240 }}
/>
</Space>
- <Button
- type="primary"
- icon={<PlusOutlined />}
- onClick={() => message.info(t('cluster.createClusterWip'))}
- >
+ <Button type="primary" icon={<PlusOutlined />}
onClick={openConnectModal}>
{t('cluster.createCluster')}
</Button>
</Flex>
@@ -1044,6 +1076,54 @@ const ClusterPage = () => {
</Descriptions>
)}
</Modal>
+ <Modal
+ title={t('cluster.testConnectionTitle')}
+ open={connectModalOpen}
+ onCancel={closeConnectModal}
+ okText={t('cluster.testConnection')}
+ cancelText={t('common.close')}
+ confirmLoading={connectTesting}
+ onOk={handleTestConnection}
+ width={560}
+ destroyOnClose
+ >
+ <Text type="secondary">{t('cluster.testConnectionDesc')}</Text>
+ <Form form={connectForm} layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item
+ name="namesrvAddr"
+ label={t('cluster.nsAddr')}
+ rules={[{ required: true, message: t('cluster.nsAddrPlaceholder')
}]}
+ >
+ <Input
+ placeholder={t('cluster.nsAddrPlaceholder')}
+ onPressEnter={handleTestConnection}
+ />
+ </Form.Item>
+ </Form>
+ {probeResult && (
+ <Descriptions column={1} bordered size="small">
+ <Descriptions.Item label={t('common.status')}>
+ <Tag color={probeResult.connected ? 'green' : 'red'}>
+ {probeResult.connected
+ ? t('cluster.testConnectionSuccess')
+ : t('cluster.testConnectionFailed')}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label={t('cluster.probeClusterName')}>
+ {probeResult.clusterName}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('cluster.probeBrokerCount')}>
+ {probeResult.brokerCount}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('cluster.probeBrokers')}>
+ {probeResult.brokerNames.length > 0 ?
probeResult.brokerNames.join(', ') : '-'}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('cluster.probeElapsed')}>
+ {probeResult.elapsedMillis}
+ </Descriptions.Item>
+ </Descriptions>
+ )}
+ </Modal>
</div>
);
};
diff --git a/web/src/services/clusterService.ts
b/web/src/services/clusterService.ts
index 374ad614..a0958d7b 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -1,6 +1,6 @@
import { USE_MOCK } from '../config';
import * as clusterApi from '../api/cluster';
-import type { ClusterConfig, ClusterInfo, K8sCertInfo } from '../api/cluster';
+import type { ClusterConfig, ClusterInfo, ClusterProbeResult, K8sCertInfo }
from '../api/cluster';
import clusters, { mockK8sCerts } from '../mock/clusters';
const mockCertStore: K8sCertInfo[] = mockK8sCerts.map((cert) => ({
@@ -34,6 +34,24 @@ export async function listClusters(): Promise<ClusterInfo[]>
{
return clusterApi.listClusters();
}
+export async function testClusterConnection(namesrvAddr: string):
Promise<ClusterProbeResult> {
+ if (USE_MOCK) {
+ const trimmed = namesrvAddr.trim();
+ const cluster = clusters[0];
+ const brokerNames = cluster ? cluster.brokers.map((broker) => broker.name)
: [];
+ return {
+ connected: true,
+ namesrvAddr: trimmed,
+ clusterName: cluster?.nsClusterName ?? 'DefaultCluster',
+ brokerCount: brokerNames.length,
+ brokerNames,
+ elapsedMillis: 12,
+ message: `Connected to ${brokerNames.length} broker(s) (mock)`,
+ };
+ }
+ return clusterApi.testClusterConnection(namesrvAddr);
+}
+
export async function getCluster(id: string): Promise<ClusterInfo> {
if (USE_MOCK) {
const cluster = clusters.find((item) => item.id === id);