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 cbe7d6533 feat(proxy,dlq): live proxy topology/health view and
dead-letter message export (#2063)
cbe7d6533 is described below
commit cbe7d653391ad76fdaf2aced27d9707753844a42
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 18 17:30:37 2026 +0800
feat(proxy,dlq): live proxy topology/health view and dead-letter message
export (#2063)
Proxy topology/health view (BASE-01 C):
- New ProxyTopologyVO + ProxyHealthProbe (TCP probe abstraction) +
SocketProxyHealthProbe default implementation.
- ProxyAddressService.buildTopology() probes every registered proxy on its
gRPC port (and the derived 5.0 remoting port for the well-known 8080/8081
pair) and reports UP / PARTIAL / DOWN with reachability flags and latency.
- GET /api/proxies/topology controller endpoint.
- Frontend: Proxy.tsx overlays the live status onto the node list
(UP->healthy, PARTIAL->warning, DOWN->unhealthy); api/proxy.ts adds
getProxyTopology.
DLQ export (BASE-01 K):
- DLQProvider.exportMessages() scans the %DLQ%<group> topic (reusing the
existing offset-legal-aware pull scan, now with a per-call cap) and maps
each dead letter to DLQMessageVO (msgId, topic, queue/offset, storeTime,
keys, UTF-8 body + lossless bodyBase64).
- GET /api/dlq/export returns the message list as a JSON attachment
(Content-Disposition: dlq-<group>.json); DLQService validates the request.
- Frontend: the DLQ row export button now downloads the dead-letter
messages as JSON (api/message.ts + services/messageService.ts +
dlq.tsx), while the batch export keeps the CSV summary.
Tests (all green): ProxyAddressServiceTest (service ctor updated + topology
UP/PARTIAL/DOWN and non-standard port cases), ProxyControllerTest (topology
endpoint), RocketMQDLQProviderTest (export mapping + cap fallback),
DLQControllerTest (export attachment), DLQProviderStubTest (export stub);
MultiBackendMetricsSourceTest address fallback fixed (upstream regression
from the SSRF guard: hosts without a site-local IPv4 fell back to loopback,
which UrlHostGuard rejects). Server suite 1061/1061; frontend DLQ 12/12 and
Proxy 7/7.
---
.../studio/cluster/proxy/ProxyAddressService.java | 167 ++++++++++++++++++++-
.../studio/cluster/proxy/ProxyController.java | 5 +
.../studio/cluster/proxy/ProxyHealthProbe.java | 44 ++++++
.../studio/cluster/proxy/ProxyTopologyVO.java | 56 +++++++
.../cluster/proxy/SocketProxyHealthProbe.java | 46 ++++++
.../studio/instance/dlq/DLQController.java | 27 ++++
.../dlq/{DLQProvider.java => DLQMessageVO.java} | 27 +++-
.../rocketmq/studio/instance/dlq/DLQProvider.java | 2 +
.../studio/instance/dlq/DLQProviderStub.java | 7 +
.../rocketmq/studio/instance/dlq/DLQService.java | 18 ++-
.../provider/apache/RocketMQDLQProvider.java | 49 +++++-
.../metrics/MultiBackendMetricsSourceTest.java | 15 +-
.../cluster/proxy/ProxyAddressServiceTest.java | 140 ++++++++++++++++-
.../studio/cluster/proxy/ProxyControllerTest.java | 35 +++++
.../studio/instance/dlq/DLQControllerTest.java | 50 ++++++
.../studio/instance/dlq/DLQProviderStubTest.java | 9 ++
.../studio/instance/dlq/DLQServiceTest.java | 35 +++++
.../provider/apache/RocketMQDLQProviderTest.java | 61 ++++++++
web/src/api/message.ts | 11 ++
web/src/api/proxy.ts | 16 ++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 23 ++-
web/src/pages/instance/dlq.tsx | 46 +++++-
web/src/pages/studio/Proxy.tsx | 33 +++-
web/src/services/messageService.ts | 11 ++
24 files changed, 903 insertions(+), 30 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
index 71279b736..578d044b2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
@@ -22,7 +22,9 @@ import
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@@ -36,8 +38,17 @@ import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Stream;
@Slf4j
@Service
@@ -50,17 +61,58 @@ public class ProxyAddressService {
private static final String RELOAD_PATH = "/admin/reloadConfig";
+ /** Default connect timeout for a single topology probe, in milliseconds.
*/
+ private static final int HEALTH_PROBE_TIMEOUT_MILLIS = 2_000;
+
+ /**
+ * Overall time budget for one topology build. Serial probing would cost
up to
+ * {@code 2 x HEALTH_PROBE_TIMEOUT_MILLIS} per DOWN node, so with many
unreachable
+ * nodes the endpoint could exceed the frontend's request timeout. Probing
runs in
+ * parallel and, once the budget elapses, unfinished probes are reported as
+ * unreachable (DOWN/PARTIAL) instead of failing the whole endpoint.
+ */
+ private static final long TOPOLOGY_TOTAL_TIMEOUT_MILLIS = 10_000L;
+
+ /** Bounded pool for the I/O-bound TCP probes; probes queue beyond this
share the budget. */
+ private static final int PROBE_EXECUTOR_THREADS = 8;
+
private final Set<String> proxyAddrs = new
LinkedHashSet<>(List.of("127.0.0.1:8081"));
private String currentProxyAddr = "127.0.0.1:8081";
private final RestTemplate restTemplate;
+ private final ProxyHealthProbe healthProbe;
+ private final ExecutorService probeExecutor;
+ private final long topologyTotalTimeoutMillis;
- public ProxyAddressService() {
+ @Autowired
+ public ProxyAddressService(ProxyHealthProbe healthProbe) {
+ this(healthProbe, defaultProbeExecutor(),
TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+ }
+
+ ProxyAddressService(ProxyHealthProbe healthProbe, ExecutorService
probeExecutor,
+ long topologyTotalTimeoutMillis) {
+ this.healthProbe = healthProbe;
+ this.probeExecutor = probeExecutor;
+ this.topologyTotalTimeoutMillis = topologyTotalTimeoutMillis;
NoRedirectClientHttpRequestFactory factory = new
NoRedirectClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(3));
factory.setReadTimeout(Duration.ofSeconds(3));
this.restTemplate = new RestTemplate(factory);
}
+ private static ExecutorService defaultProbeExecutor() {
+ AtomicInteger threadIndex = new AtomicInteger();
+ return Executors.newFixedThreadPool(PROBE_EXECUTOR_THREADS, runnable
-> {
+ Thread thread = new Thread(runnable, "proxy-health-probe-" +
threadIndex.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+
+ @PreDestroy
+ public void shutdownProbeExecutor() {
+ probeExecutor.shutdownNow();
+ }
+
public synchronized ProxyHomeVO getHomePage() {
return ProxyHomeVO.builder()
.proxyAddrList(new ArrayList<>(proxyAddrs))
@@ -68,6 +120,119 @@ public class ProxyAddressService {
.build();
}
+ /**
+ * Builds the proxy topology/health view: every registered proxy address
is probed over
+ * TCP on its gRPC port (and the derived remoting port) so the console can
show live
+ * UP/PARTIAL/DOWN status instead of an address list with no runtime
signal.
+ *
+ * <p>All probes run in parallel on a bounded executor and the whole view
is capped by
+ * {@link #topologyTotalTimeoutMillis}: probes that do not finish in time
are reported as
+ * unreachable, so a few DOWN nodes can never stall the endpoint past its
budget.
+ */
+ public List<ProxyTopologyVO> buildTopology() {
+ List<String> addrs;
+ synchronized (this) {
+ addrs = new ArrayList<>(proxyAddrs);
+ }
+ List<ProbeTask> tasks = new ArrayList<>();
+ for (String addr : addrs) {
+ Matcher matcher = PROXY_ADDR_PATTERN.matcher(addr);
+ if (!matcher.matches()) {
+ log.warn("Skipping malformed proxy address in topology: {}",
addr);
+ continue;
+ }
+ String host = matcher.group(1);
+ int grpcPort = Integer.parseInt(matcher.group(2));
+ Integer remotingPort = deriveRemotingPort(grpcPort);
+ tasks.add(new ProbeTask(addr, grpcPort, remotingPort,
+ probeAsync(host, grpcPort),
+ remotingPort != null ? probeAsync(host, remotingPort) :
null));
+ }
+ awaitProbes(tasks);
+ return tasks.stream().map(this::toTopologyVO).toList();
+ }
+
+ private ProxyTopologyVO toTopologyVO(ProbeTask task) {
+ ProxyHealthProbe.ProbeResult grpc = awaitProbe(task.grpcProbe());
+ ProxyHealthProbe.ProbeResult remoting = task.remotingProbe() != null
+ ? awaitProbe(task.remotingProbe())
+ : ProxyHealthProbe.ProbeResult.unreachable();
+ boolean grpcReachable = grpc.reachable();
+ boolean remotingReachable = task.remotingPort() != null &&
remoting.reachable();
+ String status = grpcReachable ? "UP"
+ : (remotingReachable ? "PARTIAL" : "DOWN");
+ return ProxyTopologyVO.builder()
+ .proxyAddr(task.proxyAddr())
+ .status(status)
+ .grpcPort(task.grpcPort())
+ .remotingPort(task.remotingPort())
+ .grpcReachable(grpcReachable)
+ .remotingReachable(remotingReachable)
+ .latencyMs(grpcReachable ? grpc.latencyMs() : -1L)
+ .build();
+ }
+
+ private CompletableFuture<ProxyHealthProbe.ProbeResult> probeAsync(String
host, int port) {
+ try {
+ return CompletableFuture.supplyAsync(
+ () -> healthProbe.probe(host, port,
HEALTH_PROBE_TIMEOUT_MILLIS), probeExecutor);
+ } catch (RejectedExecutionException ex) {
+ return
CompletableFuture.completedFuture(ProxyHealthProbe.ProbeResult.unreachable());
+ }
+ }
+
+ private void awaitProbes(List<ProbeTask> tasks) {
+ if (tasks.isEmpty()) {
+ return;
+ }
+ CompletableFuture<?>[] futures = tasks.stream()
+ .flatMap(task -> task.remotingProbe() == null
+ ? Stream.of(task.grpcProbe())
+ : Stream.of(task.grpcProbe(), task.remotingProbe()))
+ .toArray(CompletableFuture[]::new);
+ try {
+ CompletableFuture.allOf(futures).get(topologyTotalTimeoutMillis,
TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ log.warn("Proxy topology probing exceeded the {} ms budget;
unfinished probes are"
+ + " reported as unreachable", topologyTotalTimeoutMillis);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ log.warn("Proxy topology probing was interrupted");
+ } catch (ExecutionException ex) {
+ log.warn("Proxy topology probe failed: {}", ex.getMessage());
+ }
+ }
+
+ /**
+ * Reads a finished probe outcome; a probe that is still running (budget
exceeded), failed,
+ * or was rejected degrades to unreachable instead of propagating an error.
+ */
+ private ProxyHealthProbe.ProbeResult
awaitProbe(CompletableFuture<ProxyHealthProbe.ProbeResult> future) {
+ if (future.isDone() && !future.isCompletedExceptionally()) {
+ return future.join();
+ }
+ future.cancel(true);
+ return ProxyHealthProbe.ProbeResult.unreachable();
+ }
+
+ /** In-flight probe pair for one registered proxy address. */
+ private record ProbeTask(String proxyAddr, int grpcPort, Integer
remotingPort,
+ CompletableFuture<ProxyHealthProbe.ProbeResult>
grpcProbe,
+ CompletableFuture<ProxyHealthProbe.ProbeResult>
remotingProbe) {
+ }
+
+ /**
+ * Derives the counterpart port using the RocketMQ 5.0 default layout
(remoting
+ * {@code 8080} / gRPC {@code 8081}). Non-standard ports yield {@code
null} because the
+ * pairing cannot be assumed for custom port mappings.
+ */
+ private Integer deriveRemotingPort(int grpcPort) {
+ if (grpcPort == 8081) {
+ return 8080;
+ }
+ return grpcPort == 8080 ? 8081 : null;
+ }
+
public synchronized void addProxyAddr(String newProxyAddr) {
String normalized = normalizeProxyAddr(newProxyAddr, "newProxyAddr");
proxyAddrs.add(normalized);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
index dec10d8b6..34cba7970 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
@@ -48,6 +48,11 @@ public class ProxyController {
return Result.ok(proxies);
}
+ @GetMapping("/topology")
+ public Result<List<ProxyTopologyVO>> getProxyTopology() {
+ return Result.ok(proxyAddressService.buildTopology());
+ }
+
@PostMapping("/config/reload")
public Result<Map<String, Boolean>> reloadProxyConfig(@Valid @RequestBody
RestartProxyDTO command) {
proxyAddressService.reloadConfig(command.getAddr());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyHealthProbe.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyHealthProbe.java
new file mode 100644
index 000000000..38e2ef102
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyHealthProbe.java
@@ -0,0 +1,44 @@
+/*
+ * 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.proxy;
+
+/**
+ * TCP reachability probe used by {@link ProxyAddressService#buildTopology()}
to decide
+ * whether a proxy node is up. Abstracted so tests can simulate
reachable/unreachable
+ * ports without opening sockets.
+ */
+public interface ProxyHealthProbe {
+
+ /**
+ * Attempts a TCP connection to {@code host:port} within {@code
timeoutMillis}.
+ *
+ * @return the probe outcome with the measured latency (or -1 when
unreachable)
+ */
+ ProbeResult probe(String host, int port, int timeoutMillis);
+
+ /** Outcome of a single probe. */
+ record ProbeResult(boolean reachable, long latencyMs) {
+
+ public static ProbeResult unreachable() {
+ return new ProbeResult(false, -1L);
+ }
+
+ public static ProbeResult reachable(long latencyMs) {
+ return new ProbeResult(true, Math.max(0L, latencyMs));
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyTopologyVO.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyTopologyVO.java
new file mode 100644
index 000000000..b95f6b672
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyTopologyVO.java
@@ -0,0 +1,56 @@
+/*
+ * 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.proxy;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Health/topology view of a single 5.0 Proxy node. {@code proxyAddr} is the
+ * registered {@code host:grpcPort} address; the remoting port is derived with
the
+ * 5.0 default layout ({@code grpcPort - 1}) when it looks like the well-known
+ * {@code 8080/8081} pair, otherwise the probe only reports the gRPC side.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProxyTopologyVO {
+
+ /** Registered proxy address in {@code host:grpcPort} form. */
+ private String proxyAddr;
+
+ /** UP (gRPC reachable) / PARTIAL (gRPC down, remoting up) / DOWN. */
+ private String status;
+
+ /** gRPC port of the proxy. */
+ private int grpcPort;
+
+ /** Derived remoting port ({@code grpcPort - 1}), {@code null} when not
derivable. */
+ private Integer remotingPort;
+
+ /** Whether the gRPC port accepted a TCP connection. */
+ private boolean grpcReachable;
+
+ /** Whether the derived remoting port accepted a TCP connection
(null-safe). */
+ private boolean remotingReachable;
+
+ /** Round-trip latency of the successful probe in milliseconds, -1 when
unreachable. */
+ private long latencyMs;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/SocketProxyHealthProbe.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/SocketProxyHealthProbe.java
new file mode 100644
index 000000000..77c6678cf
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/SocketProxyHealthProbe.java
@@ -0,0 +1,46 @@
+/*
+ * 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.proxy;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+
+/**
+ * Default {@link ProxyHealthProbe} backed by a plain TCP connect. A connect
succeeds as
+ * soon as the remote end accepts the handshake, so this also works for
proxies that only
+ * listen on gRPC without exposing an HTTP health endpoint.
+ */
+@Slf4j
+@Component
+public class SocketProxyHealthProbe implements ProxyHealthProbe {
+
+ @Override
+ public ProbeResult probe(String host, int port, int timeoutMillis) {
+ long start = System.nanoTime();
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(host, port), timeoutMillis);
+ long latencyMs = (System.nanoTime() - start) / 1_000_000L;
+ return ProbeResult.reachable(latencyMs);
+ } catch (IOException exception) {
+ return ProbeResult.unreachable();
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
index 096b3b879..6487cba54 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
@@ -16,10 +16,15 @@
*/
package org.apache.rocketmq.studio.instance.dlq;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.rocketmq.studio.common.domain.Result;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -35,6 +40,7 @@ import java.util.List;
public class DLQController {
private final DLQService dlqService;
+ private final ObjectMapper objectMapper;
@GetMapping
public Result<List<DLQGroupVO>> listDLQGroups(@RequestParam String
instanceId) {
@@ -48,6 +54,27 @@ public class DLQController {
request.getStartTime(), request.getEndTime(),
request.getTargetTopic()));
}
+ @GetMapping("/export")
+ public ResponseEntity<byte[]> exportDLQMessages(@RequestParam String
instanceId,
+ @RequestParam String
groupName,
+ @RequestParam(required =
false) Long startTime,
+ @RequestParam(required =
false) Long endTime,
+ @RequestParam(required =
false) Integer maxCount) {
+ List<DLQMessageVO> messages = dlqService.exportMessages(
+ instanceId, groupName, startTime, endTime, maxCount);
+ byte[] body;
+ try {
+ body = objectMapper.writeValueAsBytes(messages);
+ } catch (JsonProcessingException exception) {
+ throw new BusinessException(500, "Failed to serialize DLQ export");
+ }
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"dlq-" + groupName + ".json\"")
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(body);
+ }
+
private void requireRequest(DLQResendRequestDTO request) {
if (request == null) {
throw new BusinessException(400, "DLQ resend request is required");
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
similarity index 57%
copy from
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
copy to
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
index e91cf484d..a27842460 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
@@ -16,11 +16,28 @@
*/
package org.apache.rocketmq.studio.instance.dlq;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
-import java.util.List;
+/**
+ * A dead-letter message as exported by the DLQ export endpoint. {@code body}
carries the
+ * UTF-8-decoded payload (best effort) while {@code bodyBase64} preserves the
exact bytes
+ * so binary messages can be exported losslessly.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DLQMessageVO {
-public interface DLQProvider {
- List<DLQGroupVO> listDLQGroups(String instanceId);
- DLQResendResultVO resendMessages(String instanceId, String groupName, Long
startTime, Long endTime,
- String targetTopic);
+ private String msgId;
+ private String topic;
+ private int queueId;
+ private long offset;
+ private long storeTime;
+ private String keys;
+ private String body;
+ private String bodyBase64;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
index e91cf484d..4f1c7dbd8 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
@@ -23,4 +23,6 @@ public interface DLQProvider {
List<DLQGroupVO> listDLQGroups(String instanceId);
DLQResendResultVO resendMessages(String instanceId, String groupName, Long
startTime, Long endTime,
String targetTopic);
+ List<DLQMessageVO> exportMessages(String instanceId, String groupName,
Long startTime, Long endTime,
+ Integer maxCount);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
index 2011a9660..bb9400c52 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
@@ -43,6 +43,13 @@ public class DLQProviderStub implements DLQProvider {
throw unsupported();
}
+ @Override
+ public List<DLQMessageVO> exportMessages(String instanceId, String
groupName, Long startTime, Long endTime,
+ Integer maxCount) {
+ log.warn("DLQProviderStub.exportMessages called but no real DLQ
provider is configured. group={}", groupName);
+ throw unsupported();
+ }
+
private BusinessException unsupported() {
return new BusinessException(501, "DLQ provider is not configured");
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
index 5923e4741..6447e0c5f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
@@ -21,7 +21,6 @@ import
org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -49,6 +48,14 @@ public class DLQService {
return dlqProvider.resendMessages(instanceId, groupName, startTime,
endTime, targetTopic);
}
+ public List<DLQMessageVO> exportMessages(String instanceId, String
groupName, Long startTime, Long endTime,
+ Integer maxCount) {
+ requireApacheInstance(instanceId);
+ validateResendRequest(groupName, startTime, endTime);
+ log.info("Exporting DLQ messages: group={}, maxCount={}", groupName,
maxCount);
+ return dlqProvider.exportMessages(instanceId, groupName, startTime,
endTime, maxCount);
+ }
+
private void requireApacheInstance(String instanceId) {
providerRegistry.byInstanceId(instanceId).ifPresent(provider -> {
if (provider.vendor() != InstanceVendor.APACHE) {
@@ -62,19 +69,16 @@ public class DLQService {
throw new BusinessException(400, "groupName is required");
}
if ((startTime == null) != (endTime == null)) {
- throw new BusinessException(
- HttpStatus.BAD_REQUEST.value(), "startTime and endTime
must be provided together");
+ throw new BusinessException(400, "startTime and endTime must be
provided together");
}
if (startTime == null) {
return;
}
if (startTime <= 0 || endTime <= 0) {
- throw new BusinessException(
- HttpStatus.BAD_REQUEST.value(), "startTime and endTime
must be positive");
+ throw new BusinessException(400, "startTime and endTime must be
positive");
}
if (endTime < startTime) {
- throw new BusinessException(
- HttpStatus.BAD_REQUEST.value(), "endTime must not be
earlier than startTime");
+ throw new BusinessException(400, "endTime must not be earlier than
startTime");
}
}
}
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 d8d8660e5..18a0c37e9 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
@@ -33,6 +33,7 @@ import org.apache.rocketmq.remoting.protocol.body.TopicList;
import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
import org.apache.rocketmq.studio.instance.dlq.DLQProvider;
import org.apache.rocketmq.studio.instance.dlq.DLQResendResultVO;
import org.apache.rocketmq.studio.ops.audit.AuditService;
@@ -43,10 +44,12 @@ import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -145,7 +148,7 @@ public class RocketMQDLQProvider implements DLQProvider {
DeadLetterScanResult scanResult;
try {
- scanResult = collectDeadLetters(endpoint, dlqTopic, begin, end);
+ scanResult = collectDeadLetters(endpoint, dlqTopic, begin, end,
RESEND_HARD_CAP);
} catch (BusinessException e) {
String detail = String.format("instanceId=%s, group=%s,
dlqTopic=%s, targetTopic=%s, "
+ "matched=0, resent=0, failed=0,
scanIncomplete=true, scanFailedQueues=all",
@@ -193,7 +196,45 @@ public class RocketMQDLQProvider implements DLQProvider {
.build();
}
- private DeadLetterScanResult collectDeadLetters(String endpoint, String
dlqTopic, long begin, long end) {
+ @Override
+ public List<DLQMessageVO> exportMessages(String instanceId, String
groupName, Long startTime, Long endTime,
+ Integer maxCount) {
+ String endpoint =
runtimeAdminClientResolver.resolveEndpoint(instanceId);
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+ long end = endTime != null ? endTime : System.currentTimeMillis();
+ long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
+ int cap = maxCount == null || maxCount <= 0 ? RESEND_HARD_CAP :
Math.min(maxCount, RESEND_HARD_CAP);
+ DeadLetterScanResult scanResult = collectDeadLetters(endpoint,
dlqTopic, begin, end, cap);
+ return scanResult.messages().stream().map(this::toExportVO).toList();
+ }
+
+ private DLQMessageVO toExportVO(MessageExt message) {
+ return DLQMessageVO.builder()
+ .msgId(message.getMsgId())
+ .topic(message.getTopic())
+ .queueId(message.getQueueId())
+ .offset(message.getQueueOffset())
+ .storeTime(message.getStoreTimestamp())
+ .keys(message.getKeys())
+ .body(toUtf8Text(message.getBody()))
+ .bodyBase64(message.getBody() == null ? null
+ :
Base64.getEncoder().encodeToString(message.getBody()))
+ .build();
+ }
+
+ private String toUtf8Text(byte[] body) {
+ if (body == null) {
+ return null;
+ }
+ try {
+ return new String(body, StandardCharsets.UTF_8);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private DeadLetterScanResult collectDeadLetters(String endpoint, String
dlqTopic, long begin, long end,
+ int cap) {
DefaultMQPullConsumer consumer = newPullConsumer(endpoint);
List<MessageExt> result = new ArrayList<>();
int failedQueueCount = 0;
@@ -212,7 +253,7 @@ public class RocketMQDLQProvider implements DLQProvider {
long maxOffset = consumer.searchOffset(queue, end);
int consecutiveIllegalOffsets = 0;
for (long offset = minOffset; offset <= maxOffset; ) {
- if (result.size() >= RESEND_HARD_CAP) {
+ if (result.size() >= cap) {
break outer;
}
PullResult pullResult = consumer.pull(queue, "*",
offset, 32);
@@ -254,7 +295,7 @@ public class RocketMQDLQProvider implements DLQProvider {
if (messageExt.getStoreTimestamp() >= begin
&& messageExt.getStoreTimestamp() <= end) {
result.add(messageExt);
- if (result.size() >= RESEND_HARD_CAP) {
+ if (result.size() >= cap) {
break outer;
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
index 74c697424..e20662ec0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
@@ -165,6 +165,7 @@ class MultiBackendMetricsSourceTest {
}
private static java.net.InetAddress findSiteLocalAddress() throws
java.net.SocketException {
+ InetAddress fallback = null;
Enumeration<NetworkInterface> interfaces =
NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
@@ -174,13 +175,23 @@ class MultiBackendMetricsSourceTest {
for (InterfaceAddress address : iface.getInterfaceAddresses()) {
InetAddress inet = address.getAddress();
if (inet instanceof java.net.Inet4Address
- && inet.isSiteLocalAddress()
&& !inet.isLoopbackAddress()
&& !inet.isLinkLocalAddress()) {
- return inet;
+ if (inet.isSiteLocalAddress()) {
+ return inet;
+ }
+ if (fallback == null) {
+ fallback = inet;
+ }
}
}
}
+ // No site-local interface (e.g. hosts that only expose public
ranges): fall back to any
+ // non-loopback IPv4 so the embedded server stays reachable under the
SSRF guard, which
+ // rejects loopback/link-local addresses (see UrlHostGuard).
+ if (fallback != null) {
+ return fallback;
+ }
return java.net.InetAddress.getLoopbackAddress();
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
index 94a14d11e..90776ba06 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
@@ -18,16 +18,30 @@
package org.apache.rocketmq.studio.cluster.proxy;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class ProxyAddressServiceTest {
- private final ProxyAddressService proxyAddressService = new
ProxyAddressService();
+ private final ProxyHealthProbe healthProbe = mock(ProxyHealthProbe.class);
+ private final ProxyAddressService proxyAddressService = new
ProxyAddressService(healthProbe);
+
+ @BeforeEach
+ void setUp() {
+ // Default probe outcome: everything reachable with 1 ms latency.
+ when(healthProbe.probe(anyString(), anyInt(), anyInt()))
+ .thenReturn(ProxyHealthProbe.ProbeResult.reachable(1L));
+ }
@Test
void homePageShouldReturnDefaultProxyAddress() {
@@ -133,4 +147,128 @@ class ProxyAddressServiceTest {
.hasMessage("addr is not a registered proxy address")
.satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
}
+
+ @Test
+ void buildTopologyShouldReportUpPartialAndDownStatus() {
+ proxyAddressService.addProxyAddr("10.0.0.2:8081");
+ proxyAddressService.addProxyAddr("10.0.0.3:8081");
+
+ // 10.0.0.2: gRPC down, remoting (8080) up → PARTIAL
+ when(healthProbe.probe(eq("10.0.0.2"), eq(8081), anyInt()))
+ .thenReturn(ProxyHealthProbe.ProbeResult.unreachable());
+ when(healthProbe.probe(eq("10.0.0.2"), eq(8080), anyInt()))
+ .thenReturn(ProxyHealthProbe.ProbeResult.reachable(2L));
+ // 10.0.0.3: both ports down → DOWN
+ when(healthProbe.probe(eq("10.0.0.3"), anyInt(), anyInt()))
+ .thenReturn(ProxyHealthProbe.ProbeResult.unreachable());
+
+ List<ProxyTopologyVO> topology = proxyAddressService.buildTopology();
+
+ assertThat(topology).hasSize(3);
+
+ ProxyTopologyVO up = topology.get(0);
+ assertThat(up.getProxyAddr()).isEqualTo("127.0.0.1:8081");
+ assertThat(up.getStatus()).isEqualTo("UP");
+ assertThat(up.getGrpcPort()).isEqualTo(8081);
+ assertThat(up.getRemotingPort()).isEqualTo(8080);
+ assertThat(up.isGrpcReachable()).isTrue();
+ assertThat(up.isRemotingReachable()).isTrue();
+ assertThat(up.getLatencyMs()).isEqualTo(1L);
+
+ ProxyTopologyVO partial = topology.get(1);
+ assertThat(partial.getProxyAddr()).isEqualTo("10.0.0.2:8081");
+ assertThat(partial.getStatus()).isEqualTo("PARTIAL");
+ assertThat(partial.isGrpcReachable()).isFalse();
+ assertThat(partial.isRemotingReachable()).isTrue();
+
+ ProxyTopologyVO down = topology.get(2);
+ assertThat(down.getProxyAddr()).isEqualTo("10.0.0.3:8081");
+ assertThat(down.getStatus()).isEqualTo("DOWN");
+ assertThat(down.isGrpcReachable()).isFalse();
+ assertThat(down.isRemotingReachable()).isFalse();
+ assertThat(down.getLatencyMs()).isEqualTo(-1L);
+ }
+
+ @Test
+ void buildTopologyShouldNotDeriveRemotingPortForNonStandardGrpcPort() {
+ proxyAddressService.addProxyAddr("10.0.0.4:8443");
+
+ List<ProxyTopologyVO> topology = proxyAddressService.buildTopology();
+
+ ProxyTopologyVO custom = topology.stream()
+ .filter(node -> node.getProxyAddr().equals("10.0.0.4:8443"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(custom.getStatus()).isEqualTo("UP");
+ assertThat(custom.getRemotingPort()).isNull();
+ assertThat(custom.isRemotingReachable()).isFalse();
+ }
+
+ @Test
+ void buildTopologyShouldProbeAddressesInParallelTest() {
+ // Every probe blocks 300 ms; 4 addresses mean 8 probes, which would
take
+ // at least 2.4 s if probed serially but only one wave (~300 ms) in
parallel.
+ ProxyHealthProbe slowProbe = (host, port, timeoutMillis) -> {
+ try {
+ Thread.sleep(300L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return ProxyHealthProbe.ProbeResult.reachable(300L);
+ };
+ ProxyAddressService service = new ProxyAddressService(slowProbe);
+ service.addProxyAddr("10.0.0.11:8081");
+ service.addProxyAddr("10.0.0.12:8081");
+ service.addProxyAddr("10.0.0.13:8081");
+
+ long start = System.nanoTime();
+ List<ProxyTopologyVO> topology = service.buildTopology();
+ long elapsedMillis = (System.nanoTime() - start) / 1_000_000L;
+
+ assertThat(topology).hasSize(4);
+ assertThat(elapsedMillis).isLessThan(2_000L);
+ }
+
+ @Test
+ void buildTopologyShouldDegradeHungProbesWithinTotalBudgetTest() {
+ // 10.0.0.21: gRPC probe hangs, remoting probe returns → PARTIAL.
+ // 10.0.0.22: both probes hang → DOWN. Neither may exceed the 500 ms
budget.
+ ProxyHealthProbe selectiveProbe = (host, port, timeoutMillis) -> {
+ boolean hang = "10.0.0.21".equals(host) ? port == 8081 :
"10.0.0.22".equals(host);
+ if (hang) {
+ try {
+ Thread.sleep(5_000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ return ProxyHealthProbe.ProbeResult.reachable(1L);
+ };
+ java.util.concurrent.ExecutorService executor =
+ java.util.concurrent.Executors.newFixedThreadPool(4);
+ ProxyAddressService service = new ProxyAddressService(selectiveProbe,
executor, 500L);
+ service.addProxyAddr("10.0.0.21:8081");
+ service.addProxyAddr("10.0.0.22:8081");
+
+ long start = System.nanoTime();
+ List<ProxyTopologyVO> topology = service.buildTopology();
+ long elapsedMillis = (System.nanoTime() - start) / 1_000_000L;
+ executor.shutdownNow();
+
+ assertThat(elapsedMillis).isLessThan(3_000L);
+ assertThat(topology).hasSize(3);
+
+ ProxyTopologyVO partial = topology.get(1);
+ assertThat(partial.getProxyAddr()).isEqualTo("10.0.0.21:8081");
+ assertThat(partial.getStatus()).isEqualTo("PARTIAL");
+ assertThat(partial.isGrpcReachable()).isFalse();
+ assertThat(partial.isRemotingReachable()).isTrue();
+ assertThat(partial.getLatencyMs()).isEqualTo(-1L);
+
+ ProxyTopologyVO down = topology.get(2);
+ assertThat(down.getProxyAddr()).isEqualTo("10.0.0.22:8081");
+ assertThat(down.getStatus()).isEqualTo("DOWN");
+ assertThat(down.isGrpcReachable()).isFalse();
+ assertThat(down.isRemotingReachable()).isFalse();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
index ee3d9404e..01c2c3ee0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
@@ -127,6 +127,41 @@ class ProxyControllerTest {
.andExpect(jsonPath("$.message").value("clusterId is
required"));
}
+ @Test
+ void proxyTopologyShouldReturnHealthView() throws Exception {
+ when(proxyAddressService.buildTopology())
+ .thenReturn(List.of(
+ ProxyTopologyVO.builder()
+ .proxyAddr("127.0.0.1:8081")
+ .status("UP")
+ .grpcPort(8081)
+ .remotingPort(8080)
+ .grpcReachable(true)
+ .remotingReachable(true)
+ .latencyMs(1L)
+ .build(),
+ ProxyTopologyVO.builder()
+ .proxyAddr("10.0.0.2:8081")
+ .status("DOWN")
+ .grpcPort(8081)
+ .remotingPort(8080)
+ .grpcReachable(false)
+ .remotingReachable(false)
+ .latencyMs(-1L)
+ .build()));
+
+ mockMvc.perform(get("/api/proxies/topology"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+
.andExpect(jsonPath("$.data[0].proxyAddr").value("127.0.0.1:8081"))
+ .andExpect(jsonPath("$.data[0].status").value("UP"))
+ .andExpect(jsonPath("$.data[0].grpcReachable").value(true))
+ .andExpect(jsonPath("$.data[1].status").value("DOWN"))
+ .andExpect(jsonPath("$.data[1].latencyMs").value(-1));
+
+ verify(proxyAddressService).buildTopology();
+ }
+
@Test
void reloadProxyConfigShouldReturnSuccess() throws Exception {
RestartProxyDTO request = RestartProxyDTO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
index 00aca6d3a..a6063bd1c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
@@ -23,6 +23,7 @@ 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.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@@ -37,6 +38,8 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -176,4 +179,51 @@ class DLQControllerTest {
verifyNoInteractions(dlqService);
}
+
+ @Test
+ void exportDLQMessagesShouldReturnJsonAttachment() throws Exception {
+ when(dlqService.exportMessages(eq("instance-1"), eq("test-group"),
isNull(), isNull(), isNull()))
+ .thenReturn(List.of(
+ DLQMessageVO.builder()
+ .msgId("msg-1")
+ .topic("%DLQ%test-group")
+ .queueId(0)
+ .offset(5L)
+ .storeTime(150L)
+ .keys("key-a")
+ .body("hello dlq")
+ .bodyBase64("aGVsbG8gZGxx")
+ .build()));
+
+ mockMvc.perform(get("/api/dlq/export")
+ .param("instanceId", "instance-1")
+ .param("groupName", "test-group"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"dlq-test-group.json\""))
+
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$[0].msgId").value("msg-1"))
+ .andExpect(jsonPath("$[0].body").value("hello dlq"));
+
+ verify(dlqService).exportMessages(eq("instance-1"), eq("test-group"),
isNull(), isNull(), isNull());
+ }
+
+ @Test
+ void exportDLQMessagesShouldPassTimeRangeTest() throws Exception {
+ when(dlqService.exportMessages(eq("instance-1"), eq("test-group"),
eq(1000L), eq(2000L), eq(100)))
+ .thenReturn(List.of());
+
+ mockMvc.perform(get("/api/dlq/export")
+ .param("instanceId", "instance-1")
+ .param("groupName", "test-group")
+ .param("startTime", "1000")
+ .param("endTime", "2000")
+ .param("maxCount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"dlq-test-group.json\""))
+
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
+
+ verify(dlqService).exportMessages(eq("instance-1"), eq("test-group"),
eq(1000L), eq(2000L), eq(100));
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
index 69700556f..7b3483532 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
@@ -43,4 +43,13 @@ class DLQProviderStubTest {
.extracting("code")
.isEqualTo(501);
}
+
+ @Test
+ void exportMessagesShouldFailExplicitlyWhenRealProviderIsMissing() {
+ assertThatThrownBy(() -> provider.exportMessages("instance-1",
"group-1", 1000L, 2000L, 100))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("DLQ provider is not configured")
+ .extracting("code")
+ .isEqualTo(501);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
index 8f33cefc4..7936cc444 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.instance.dlq;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -125,4 +126,38 @@ class DLQServiceTest {
verifyNoInteractions(dlqProvider);
}
+
+ @Test
+ void exportMessagesShouldDelegateToProviderTest() {
+ dlqService.exportMessages("instance-1", "group-1", 1000L, 2000L, 100);
+
+ verify(dlqProvider).exportMessages("instance-1", "group-1", 1000L,
2000L, 100);
+ }
+
+ @Test
+ void exportMessagesShouldAcceptNullTimeRangeTest() {
+ dlqService.exportMessages("instance-1", "group-1", null, null, null);
+
+ verify(dlqProvider).exportMessages("instance-1", "group-1", null,
null, null);
+ }
+
+ @Test
+ void exportMessagesShouldRejectPartialTimeRangeTest() {
+ assertThatThrownBy(() -> dlqService.exportMessages("instance-1",
"group-1", 1000L, null, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("startTime and endTime must be provided together")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+
+ verifyNoInteractions(dlqProvider);
+ }
+
+ @Test
+ void exportMessagesShouldRejectReversedTimeRangeTest() {
+ assertThatThrownBy(() -> dlqService.exportMessages("instance-1",
"group-1", 2000L, 1000L, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("endTime must not be earlier than startTime")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+
+ verifyNoInteractions(dlqProvider);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
index d43e5da28..b7e9f37e4 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
@@ -33,6 +33,7 @@ import
org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
import org.apache.rocketmq.studio.ops.audit.AuditService;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.junit.jupiter.api.BeforeEach;
@@ -44,6 +45,8 @@ import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -375,4 +378,62 @@ class RocketMQDLQProviderTest {
assertThat(first).startsWith("studio-dlq-resend-");
assertThat(second).startsWith("studio-dlq-resend-").isNotEqualTo(first);
}
+
+ @Test
+ void exportMessagesReturnsMappedDeadLetterMessages() throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ MessageQueue queue = new MessageQueue(dlqTopic, "broker-a", 0);
+ MessageExt deadLetter = new MessageExt();
+ deadLetter.setMsgId("msg-1");
+ deadLetter.setTopic(dlqTopic);
+ deadLetter.setQueueId(0);
+ deadLetter.setQueueOffset(5L);
+ deadLetter.setStoreTimestamp(150L);
+ deadLetter.setKeys("key-a,key-b");
+ deadLetter.setBody("hello dlq".getBytes(StandardCharsets.UTF_8));
+ PullResult pullResult = new PullResult(PullStatus.FOUND, 1L, 0L, 0L,
List.of(deadLetter));
+ try (MockedConstruction<DefaultMQPullConsumer> mockedConsumers =
+ mockConstruction(DefaultMQPullConsumer.class, (consumer,
context) -> {
+ doNothing().when(consumer).start();
+
when(consumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
+ when(consumer.searchOffset(eq(queue),
anyLong())).thenReturn(0L);
+ when(consumer.pull(eq(queue), eq("*"), eq(0L),
eq(32))).thenReturn(pullResult);
+ doNothing().when(consumer).shutdown();
+ })) {
+ List<DLQMessageVO> exported =
+ provider.exportMessages("instance-a", "group-a", 100L,
200L, 1000);
+
+ assertThat(exported).hasSize(1);
+ DLQMessageVO vo = exported.get(0);
+ assertThat(vo.getMsgId()).isEqualTo("msg-1");
+ assertThat(vo.getTopic()).isEqualTo(dlqTopic);
+ assertThat(vo.getQueueId()).isEqualTo(0);
+ assertThat(vo.getOffset()).isEqualTo(5L);
+ assertThat(vo.getStoreTime()).isEqualTo(150L);
+ assertThat(vo.getKeys()).isEqualTo("key-a,key-b");
+ assertThat(vo.getBody()).isEqualTo("hello dlq");
+ assertThat(vo.getBodyBase64())
+ .isEqualTo(Base64.getEncoder().encodeToString("hello
dlq".getBytes(StandardCharsets.UTF_8)));
+ }
+ }
+
+ @Test
+ void exportMessagesHonorsMaxCountCap() throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ MessageQueue queue = new MessageQueue(dlqTopic, "broker-a", 0);
+ try (MockedConstruction<DefaultMQPullConsumer> mockedConsumers =
+ mockConstruction(DefaultMQPullConsumer.class, (consumer,
context) -> {
+ doNothing().when(consumer).start();
+
when(consumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
+ when(consumer.searchOffset(eq(queue),
anyLong())).thenReturn(0L);
+ when(consumer.pull(eq(queue), eq("*"), eq(0L),
eq(32)))
+ .thenReturn(new
PullResult(PullStatus.NO_NEW_MSG, 1L, 0L, 0L, List.of()));
+ doNothing().when(consumer).shutdown();
+ })) {
+ // maxCount=0 falls back to the hard cap instead of failing; scan
still completes.
+ List<DLQMessageVO> exported =
+ provider.exportMessages("instance-a", "group-a", 100L,
200L, 0);
+ assertThat(exported).isEmpty();
+ }
+ }
}
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index 30f750031..023283a1a 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -107,3 +107,14 @@ export async function resendDLQ(data: {
const res = await client.post<{ data: DLQResendResult }>('/dlq/resend',
data);
return res.data.data;
}
+
+export async function exportDLQMessages(params: {
+ instanceId: string;
+ groupName: string;
+ startTime?: number;
+ endTime?: number;
+ maxCount?: number;
+}): Promise<Blob> {
+ const res = await client.get<Blob>('/dlq/export', { params, responseType:
'blob' });
+ return res.data;
+}
diff --git a/web/src/api/proxy.ts b/web/src/api/proxy.ts
index aabab4629..b4d5003e9 100644
--- a/web/src/api/proxy.ts
+++ b/web/src/api/proxy.ts
@@ -37,6 +37,16 @@ export interface ProxyNode {
isSelected: boolean;
}
+export interface ProxyTopologyNode {
+ proxyAddr: string;
+ status: 'UP' | 'PARTIAL' | 'DOWN';
+ grpcPort: number;
+ remotingPort: number | null;
+ grpcReachable: boolean;
+ remotingReachable: boolean;
+ latencyMs: number;
+}
+
// ─── API Functions ───────────────────────────────────────────────
export async function queryProxyHomePage(): Promise<ProxyHomePageData> {
@@ -44,6 +54,12 @@ export async function queryProxyHomePage():
Promise<ProxyHomePageData> {
return res.data.data;
}
+/** Live TCP health/topology view of every registered proxy node. */
+export async function getProxyTopology(): Promise<ProxyTopologyNode[]> {
+ const res = await client.get<{ data: ProxyTopologyNode[]
}>('/proxies/topology');
+ return res.data.data;
+}
+
/**
* Trigger a configuration hot-reload for a proxy.
* Uses the same DTO as restartProxy ({ clusterId, addr }).
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 03ffa20e3..060944897 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -29,6 +29,7 @@ import DLQPage from '../dlq';
vi.mock('../../../services/messageService', () => ({
listDLQGroups: vi.fn(),
resendDLQ: vi.fn(),
+ exportDLQMessages: vi.fn(),
}));
vi.mock('../../../services/instanceService', () => ({
listInstances: vi.fn().mockResolvedValue([
@@ -189,16 +190,29 @@ describe('DLQ page', () => {
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
});
- it('exports the selected group summary as CSV', async () => {
+ it('exports the dead-letter messages of a group as JSON', async () => {
+ vi.mocked(messageService.exportDLQMessages).mockResolvedValue(
+ new
Blob(['[{"msgId":"m1","topic":"%DLQ%cg-order","queueId":0,"offset":5}]'], {
+ type: 'application/json',
+ }),
+ );
const user = userEvent.setup();
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
await user.click(screen.getByRole('button', { name: '导出' }));
+ expect(messageService.exportDLQMessages).toHaveBeenCalledTimes(1);
+ const exportParams =
vi.mocked(messageService.exportDLQMessages).mock.calls[0][0];
+ expect(exportParams.instanceId).toBe('instance-1');
+ expect(exportParams.groupName).toBe('cg-order');
+ expect(typeof exportParams.startTime).toBe('number');
+ expect(typeof exportParams.endTime).toBe('number');
+ // Default export window is the last day, mirroring the visible range
picker.
+ expect(exportParams.endTime! - exportParams.startTime!).toBeGreaterThan(23
* 3600_000);
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as Blob;
- await
expect(blob.text()).resolves.toContain('"cg-order","%DLQ%cg-order","7","3","ACTIVE"');
+ await expect(blob.text()).resolves.toContain('"msgId":"m1"');
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
});
@@ -228,7 +242,7 @@ describe('DLQ page', () => {
expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
});
- it('neutralizes formulas hidden behind a leading line feed in CSV exports',
async () => {
+ it('neutralizes formulas hidden behind a leading line feed in CSV summary
exports', async () => {
vi.mocked(messageService.listDLQGroups).mockResolvedValue([
{
...dlqGroup,
@@ -241,7 +255,8 @@ describe('DLQ page', () => {
const row = (await screen.findByText('%DLQ%formula')).closest('tr');
if (!row) throw new Error('DLQ group row not found');
- await user.click(within(row).getByRole('button', { name: '导出' }));
+ await user.click(within(row).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: /批量导出/ }));
const blob = createObjectURL.mock.calls[0][0] as Blob;
await expect(blob.text()).resolves.toContain('"\'\n=1+1","%DLQ%formula"');
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 583b387f0..56c9e17c5 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -37,7 +37,7 @@ import PageHeader from '../../components/PageHeader';
import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
import type { DLQGroup } from '../../api/message';
-import { listDLQGroups, resendDLQ } from '../../services/messageService';
+import { exportDLQMessages, listDLQGroups, resendDLQ } from
'../../services/messageService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
@@ -106,6 +106,13 @@ const DLQPage = () => {
dayjs().subtract(1, 'day'),
dayjs(),
]);
+ // Time range applied to dead-letter message exports. Kept page-level and
+ // visible so users know exactly which window the export covers instead of
+ // silently falling back to the backend's last-hour default.
+ const [exportRange, setExportRange] = useState<[Dayjs, Dayjs]>([
+ dayjs().subtract(1, 'day'),
+ dayjs(),
+ ]);
const [retryTargetTopic, setRetryTargetTopic] = useState('');
const [retrySubmitting, setRetrySubmitting] = useState(false);
const [detailGroup, setDetailGroup] = useState<DLQGroup | null>(null);
@@ -256,9 +263,24 @@ const DLQPage = () => {
}
};
- const handleExport = (group: DLQGroup) => {
- exportDLQGroups([group], `${group.groupName}-dlq.csv`);
- message.success(`已导出 ${group.groupName} 的死信队列摘要`);
+ const handleExport = async (group: DLQGroup) => {
+ try {
+ const blob = await exportDLQMessages({
+ instanceId: selectedInstanceId,
+ groupName: group.groupName,
+ startTime: exportRange[0].valueOf(),
+ endTime: exportRange[1].valueOf(),
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${group.groupName}-dlq-messages.json`;
+ link.click();
+ URL.revokeObjectURL(url);
+ message.success(`已导出 ${group.groupName} 的死信消息(${blob.size} 字节)`);
+ } catch (error) {
+ message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
+ }
};
const handleBatchExport = () => {
@@ -389,6 +411,22 @@ const DLQPage = () => {
style={{ width: 320 }}
prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
/>
+ <Space size={8}>
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ 导出时间范围
+ </Text>
+ <RangePicker
+ showTime
+ format="YYYY-MM-DD HH:mm:ss"
+ value={exportRange}
+ onChange={(vals) => {
+ if (vals && vals[0] && vals[1]) {
+ setExportRange([vals[0], vals[1]]);
+ }
+ }}
+ style={{ width: 380 }}
+ />
+ </Space>
</Space>
<Button
icon={<Download size={16} />}
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 17b797248..c7665bd4f 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -45,7 +45,12 @@ import {
} from '@phosphor-icons/react';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
-import { queryProxyHomePage, reloadProxyConfig, type ProxyNode } from
'../../api/proxy';
+import {
+ getProxyTopology,
+ queryProxyHomePage,
+ reloadProxyConfig,
+ type ProxyNode,
+} from '../../api/proxy';
const { Text } = Typography;
@@ -84,7 +89,7 @@ const ProxyPage: React.FC = () => {
try {
const { proxyAddrList, currentProxyAddr } = await queryProxyHomePage();
if (requestId !== loadRequestId.current) return false;
- const nodes: ProxyNode[] = (proxyAddrList || []).map((addr) => ({
+ const baseNodes: ProxyNode[] = (proxyAddrList || []).map((addr) => ({
key: addr,
address: addr,
status: 'unknown' as const,
@@ -96,6 +101,30 @@ const ProxyPage: React.FC = () => {
uptime: null,
isSelected: addr === currentProxyAddr,
}));
+ let nodes = baseNodes;
+
+ // Overlay the live TCP health view (UP/PARTIAL/DOWN) when the backend
exposes it.
+ try {
+ const topology = await getProxyTopology();
+ if (requestId !== loadRequestId.current) return false;
+ const statusByAddr = new Map(
+ topology.map((node) => [node.proxyAddr, node.status]),
+ );
+ nodes = baseNodes.map((node) => {
+ const probeStatus = statusByAddr.get(node.address);
+ const status: ProxyNode['status'] =
+ probeStatus === 'UP'
+ ? 'healthy'
+ : probeStatus === 'PARTIAL'
+ ? 'warning'
+ : probeStatus === 'DOWN'
+ ? 'unhealthy'
+ : node.status;
+ return { ...node, status };
+ });
+ } catch {
+ // Health probing is best-effort; keep the unknown status when it is
unavailable.
+ }
setProxyNodes(nodes);
setClusterStats({
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index 81a0de42b..72c060fb4 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -75,3 +75,14 @@ export async function resendDLQ(data: {
if (isMockMode()) return { matched: 0, resent: 0, failed: 0, outcome:
'SUCCESS' };
return messageApi.resendDLQ(data);
}
+
+export async function exportDLQMessages(params: {
+ instanceId: string;
+ groupName: string;
+ startTime?: number;
+ endTime?: number;
+ maxCount?: number;
+}): Promise<Blob> {
+ if (isMockMode()) return new Blob(['[]'], { type: 'application/json' });
+ return messageApi.exportDLQMessages(params);
+}