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 f1d4a17f2 feat(dlq): message details, selected resend and Excel export
(#2513)
f1d4a17f2 is described below
commit f1d4a17f2df42e972d1667855eb0e970cfd06801
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 25 16:57:08 2026 +0800
feat(dlq): message details, selected resend and Excel export (#2513)
- DLQProvider: add listMessages (paged details), resendMessages by msgId
and exportExcel; Apache impl scans %DLQ%group via pull consumer and
re-delivers selected dead letters to their origin/target topic
- DLQController: GET /api/dlq/{group}/messages (paged detail),
POST /api/dlq/resend-selected (by msgId), GET /api/dlq/export-excel
(single or batch); Excel via EasyExcel 2.2.10 (xlsx)
- dlq page: replace the group-stats dialog with a message detail drawer
(paged table, row selection, single/batch resend, Excel export); row
export now downloads .xlsx instead of .json
---
server/pom.xml | 6 +
.../common/exception/GlobalExceptionHandler.java | 16 +
.../studio/instance/dlq/DLQController.java | 84 ++++-
...LQProvider.java => DLQExcelExportResultVO.java} | 29 +-
.../studio/instance/dlq/DLQMessageExcelRow.java | 63 ++++
.../rocketmq/studio/instance/dlq/DLQProvider.java | 21 ++
.../studio/instance/dlq/DLQProviderStub.java | 22 ++
...vider.java => DLQResendSelectedRequestDTO.java} | 29 +-
.../rocketmq/studio/instance/dlq/DLQService.java | 35 ++
.../provider/apache/RocketMQDLQProvider.java | 131 ++++++++
.../studio/instance/dlq/DLQControllerTest.java | 182 ++++++++++
web/src/api/dlq.test.ts | 56 +++-
web/src/api/message.ts | 69 ++++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 47 ++-
web/src/pages/instance/dlq.tsx | 371 +++++++++++++++++----
web/src/services/messageService.ts | 45 +++
16 files changed, 1091 insertions(+), 115 deletions(-)
diff --git a/server/pom.xml b/server/pom.xml
index ca6c5c04d..622dcea09 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -124,6 +124,12 @@
<artifactId>kotlin-stdlib</artifactId>
<version>1.9.25</version>
</dependency>
+ <!-- Excel export for DLQ message details (single / batch). -->
+ <dependency>
+ <groupId>com.alibaba</groupId>
+ <artifactId>easyexcel</artifactId>
+ <version>3.3.4</version>
+ </dependency>
</dependencies>
<build>
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
index e0b3c33f0..2fe73fffe 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.common.exception;
+import jakarta.validation.ConstraintViolationException;
import org.apache.rocketmq.studio.cluster.metrics.PrometheusException;
import org.apache.rocketmq.studio.common.domain.Result;
import org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
@@ -75,6 +76,21 @@ public class GlobalExceptionHandler {
return Result.error(HttpStatus.BAD_REQUEST.value(), message);
}
+ /**
+ * Constraint violations on {@code @Validated} request parameters (e.g.
{@code @Size}
+ * on a {@code @RequestParam}) surface as {@link
ConstraintViolationException} rather
+ * than {@link MethodArgumentNotValidException}; report them as 400 as
well.
+ */
+ @ExceptionHandler(ConstraintViolationException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Result<?>
handleConstraintViolationException(ConstraintViolationException ex) {
+ String message = ex.getConstraintViolations().stream()
+ .findFirst()
+ .map(violation -> violation.getMessage() == null ? "Invalid
request" : violation.getMessage())
+ .orElse("Invalid request");
+ return Result.error(HttpStatus.BAD_REQUEST.value(), message);
+ }
+
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?>
handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) {
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 d5b69b379..f2444bbbd 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
@@ -22,12 +22,17 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.domain.Result;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -35,15 +40,20 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
+import java.util.List;
@RestController
@RequestMapping("/api/dlq")
@RequiredArgsConstructor
+@Validated
public class DLQController {
private static final String HEADER_EXPORT_TRUNCATED =
"X-DLQ-Export-Truncated";
private static final String HEADER_EXPORT_FAILED_QUEUES =
"X-DLQ-Export-FailedQueues";
private static final String HEADER_EXPORT_LIMIT = "X-DLQ-Export-Limit";
+ private static final String EXCEL_MEDIA_TYPE =
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ private static final int MAX_SELECTED_MESSAGES = 100;
private final DLQService dlqService;
private final ObjectMapper objectMapper;
@@ -63,6 +73,46 @@ public class DLQController {
request.getStartTime(), request.getEndTime(),
request.getTargetTopic()));
}
+ @PostMapping("/resend-selected")
+ public Result<DLQResendResultVO> resendSelectedMessages(
+ @Valid @RequestBody(required = false) DLQResendSelectedRequestDTO
request) {
+ requireSelectedRequest(request);
+ return
Result.ok(dlqService.resendSelectedMessages(request.getInstanceId(),
request.getGroupName(),
+ request.getMsgIds(), request.getTargetTopic()));
+ }
+
+ @GetMapping("/{groupName}/messages")
+ public Result<PageResult<DLQMessageVO>> listDLQMessages(@PathVariable
String groupName,
+ @RequestParam String instanceId,
+ @RequestParam(required = false) Long startTime,
+ @RequestParam(required = false) Long endTime,
+ @Min(value = 1, message = "page must be at least 1")
+ @RequestParam(defaultValue = "1") int page,
+ @Min(value = 1, message = "pageSize must be at least 1")
+ @Max(value = 100, message = "pageSize must not exceed 100")
+ @RequestParam(defaultValue = "20") int pageSize) {
+ return Result.ok(dlqService.listMessages(instanceId, groupName,
startTime, endTime, page, pageSize));
+ }
+
+ @GetMapping("/export-excel")
+ public ResponseEntity<byte[]> exportDLQExcel(@RequestParam String
instanceId,
+ @RequestParam String
groupName,
+ @RequestParam(required =
false) Long startTime,
+ @RequestParam(required =
false) Long endTime,
+ @Size(max =
MAX_SELECTED_MESSAGES,
+ message = "At most
100 msgIds are allowed per export")
+ @RequestParam(required =
false) List<String> msgIds) {
+ DLQExcelExportResultVO result = dlqService.exportExcel(instanceId,
groupName, startTime, endTime, msgIds);
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ attachmentDisposition("dlq-" +
sanitizeForFilename(groupName) + ".xlsx").toString())
+ .header(HEADER_EXPORT_TRUNCATED,
String.valueOf(result.isTruncated()))
+ .header(HEADER_EXPORT_FAILED_QUEUES,
String.valueOf(result.getFailedQueueCount()))
+ .header(HEADER_EXPORT_LIMIT, String.valueOf(result.getLimit()))
+ .contentType(MediaType.parseMediaType(EXCEL_MEDIA_TYPE))
+ .body(result.getData());
+ }
+
@GetMapping("/export")
public ResponseEntity<byte[]> exportDLQMessages(@RequestParam String
instanceId,
@RequestParam String
groupName,
@@ -77,18 +127,9 @@ public class DLQController {
} catch (JsonProcessingException exception) {
throw new BusinessException(500, "Failed to serialize DLQ export");
}
- String fileName = "dlq-" + sanitizeForFilename(groupName) + ".json";
- ContentDisposition.Builder builder = ContentDisposition.attachment();
- if (fileName.chars().allMatch(ch -> ch < 128)) {
- builder.filename(fileName);
- } else {
- // Non-ASCII names need the RFC 5987 filename* parameter so
browsers keep the
- // original characters instead of the lossy ASCII fallback.
- builder.filename(fileName, StandardCharsets.UTF_8);
- }
- ContentDisposition disposition = builder.build();
return ResponseEntity.ok()
- .header(HttpHeaders.CONTENT_DISPOSITION,
disposition.toString())
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ attachmentDisposition("dlq-" +
sanitizeForFilename(groupName) + ".json").toString())
.header(HEADER_EXPORT_TRUNCATED,
String.valueOf(result.isTruncated()))
.header(HEADER_EXPORT_FAILED_QUEUES,
String.valueOf(result.getFailedQueueCount()))
.header(HEADER_EXPORT_LIMIT, String.valueOf(result.getLimit()))
@@ -96,6 +137,21 @@ public class DLQController {
.body(body);
}
+ /**
+ * Builds an {@code attachment} Content-Disposition; non-ASCII names need
the RFC 5987
+ * {@code filename*} parameter so browsers keep the original characters
instead of the
+ * lossy ASCII fallback.
+ */
+ private static ContentDisposition attachmentDisposition(String fileName) {
+ ContentDisposition.Builder builder = ContentDisposition.attachment();
+ if (fileName.chars().allMatch(ch -> ch < 128)) {
+ builder.filename(fileName);
+ } else {
+ builder.filename(fileName, StandardCharsets.UTF_8);
+ }
+ return builder.build();
+ }
+
/**
* Strips the characters that would break a quoted RFC 6266 header value
or confuse
* file managers; the non-ASCII part is preserved and emitted as an RFC
5987
@@ -115,4 +171,10 @@ public class DLQController {
throw new BusinessException(400, "DLQ resend request is required");
}
}
+
+ private void requireSelectedRequest(DLQResendSelectedRequestDTO 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/DLQExcelExportResultVO.java
similarity index 59%
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/DLQExcelExportResultVO.java
index 2669f66a2..48f4132de 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/DLQExcelExportResultVO.java
@@ -16,17 +16,24 @@
*/
package org.apache.rocketmq.studio.instance.dlq;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
-import java.util.List;
-import org.apache.rocketmq.studio.common.domain.PageResult;
-
-public interface DLQProvider {
- List<DLQGroupVO> listDLQGroups(String instanceId);
-
- PageResult<DLQGroupVO> listDLQGroups(String instanceId, String search, int
page, int pageSize);
+/**
+ * An Excel DLQ export: the serialized .xlsx bytes plus the same
scan-completeness
+ * metadata as the JSON export, so callers can tell whether the sheet covers a
full
+ * snapshot or a bounded/partial one.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DLQExcelExportResultVO {
- DLQResendResultVO resendMessages(String instanceId, String groupName, Long
startTime, Long endTime,
- String targetTopic);
- DLQExportResultVO exportMessages(String instanceId, String groupName, Long
startTime, Long endTime,
- Integer maxCount);
+ private byte[] data;
+ private boolean truncated;
+ private int failedQueueCount;
+ private int limit;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
new file mode 100644
index 000000000..0b8e7de26
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.dlq;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Excel row model for dead-letter message export (single message or a
selected batch).
+ */
+@Data
+public class DLQMessageExcelRow {
+
+ private static final DateTimeFormatter STORE_TIME_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ @ExcelProperty("Message ID")
+ private String msgId;
+ @ExcelProperty("Topic")
+ private String topic;
+ @ExcelProperty("Queue ID")
+ private int queueId;
+ @ExcelProperty("Offset")
+ private long offset;
+ @ExcelProperty("Store Time")
+ private String storeTime;
+ @ExcelProperty("Keys")
+ private String keys;
+ @ExcelProperty("Body")
+ private String body;
+
+ public static DLQMessageExcelRow from(DLQMessageVO vo) {
+ DLQMessageExcelRow row = new DLQMessageExcelRow();
+ row.setMsgId(vo.getMsgId());
+ row.setTopic(vo.getTopic());
+ row.setQueueId(vo.getQueueId());
+ row.setOffset(vo.getOffset());
+ row.setStoreTime(LocalDateTime.ofInstant(
+ Instant.ofEpochMilli(vo.getStoreTime()),
ZoneId.systemDefault()).format(STORE_TIME_FORMAT));
+ row.setKeys(vo.getKeys());
+ row.setBody(vo.getBody());
+ return row;
+ }
+}
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 2669f66a2..3d36f7c60 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
@@ -27,6 +27,27 @@ public interface DLQProvider {
DLQResendResultVO resendMessages(String instanceId, String groupName, Long
startTime, Long endTime,
String targetTopic);
+
DLQExportResultVO exportMessages(String instanceId, String groupName, Long
startTime, Long endTime,
Integer maxCount);
+
+ /**
+ * Paged dead-letter message detail query for a group within a time window.
+ */
+ PageResult<DLQMessageVO> listMessages(String instanceId, String groupName,
Long startTime, Long endTime,
+ int page, int pageSize);
+
+ /**
+ * Re-deliver only the selected dead-letter messages (by msgId) back to
their origin or a target topic.
+ */
+ DLQResendResultVO resendMessages(String instanceId, String groupName,
List<String> msgIds,
+ String targetTopic);
+
+ /**
+ * Excel (.xlsx) export of dead-letter messages; when {@code msgIds} is
non-empty only those
+ * messages are included, otherwise the whole time window is exported. The
result carries the
+ * serialized sheet plus scan-completeness metadata, mirroring the JSON
export.
+ */
+ DLQExcelExportResultVO exportExcel(String instanceId, String groupName,
Long startTime, Long endTime,
+ List<String> msgIds);
}
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 a6e209e00..049261485 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
@@ -58,6 +58,28 @@ public class DLQProviderStub implements DLQProvider {
throw unsupported();
}
+ @Override
+ public PageResult<DLQMessageVO> listMessages(String instanceId, String
groupName, Long startTime, Long endTime,
+ int page, int pageSize) {
+ log.warn("DLQProviderStub.listMessages called but no real DLQ provider
is configured. group={}", groupName);
+ throw unsupported();
+ }
+
+ @Override
+ public DLQResendResultVO resendMessages(String instanceId, String
groupName, List<String> msgIds,
+ String targetTopic) {
+ log.warn("DLQProviderStub.resendMessages(selected) called but no real
DLQ provider is configured. group={}",
+ groupName);
+ throw unsupported();
+ }
+
+ @Override
+ public DLQExcelExportResultVO exportExcel(String instanceId, String
groupName, Long startTime, Long endTime,
+ List<String> msgIds) {
+ log.warn("DLQProviderStub.exportExcel 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/DLQProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQResendSelectedRequestDTO.java
similarity index 55%
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/DLQResendSelectedRequestDTO.java
index 2669f66a2..73c20c266 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/DLQResendSelectedRequestDTO.java
@@ -16,17 +16,30 @@
*/
package org.apache.rocketmq.studio.instance.dlq;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
import java.util.List;
-import org.apache.rocketmq.studio.common.domain.PageResult;
-public interface DLQProvider {
- List<DLQGroupVO> listDLQGroups(String instanceId);
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DLQResendSelectedRequestDTO {
+ @NotBlank(message = "instanceId is required")
+ private String instanceId;
- PageResult<DLQGroupVO> listDLQGroups(String instanceId, String search, int
page, int pageSize);
+ @NotBlank(message = "groupName is required")
+ private String groupName;
- DLQResendResultVO resendMessages(String instanceId, String groupName, Long
startTime, Long endTime,
- String targetTopic);
- DLQExportResultVO exportMessages(String instanceId, String groupName, Long
startTime, Long endTime,
- Integer maxCount);
+ @NotEmpty(message = "At least one msgId is required")
+ @Size(max = 100, message = "At most 100 msgIds are allowed per resend")
+ private List<String> msgIds;
+
+ private String targetTopic;
}
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 860300ebd..87434d9a1 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
@@ -33,6 +33,7 @@ import java.util.List;
public class DLQService {
private static final int MAX_PAGE_SIZE = 100;
+ private static final int MAX_SELECTED_MESSAGES = 100;
private final DLQProvider dlqProvider;
private final InstanceProviderRegistry providerRegistry;
@@ -68,6 +69,40 @@ public class DLQService {
return dlqProvider.exportMessages(instanceId, groupName, startTime,
endTime, maxCount);
}
+ public PageResult<DLQMessageVO> listMessages(String instanceId, String
groupName, Long startTime, Long endTime,
+ int page, int pageSize) {
+ requireApacheInstance(instanceId);
+ validateResendRequest(groupName, startTime, endTime);
+ log.info("Listing DLQ messages: group={}, page={}, pageSize={}",
groupName, page, pageSize);
+ return dlqProvider.listMessages(instanceId, groupName, startTime,
endTime, page, pageSize);
+ }
+
+ public DLQResendResultVO resendSelectedMessages(String instanceId, String
groupName, List<String> msgIds,
+ String targetTopic) {
+ requireApacheInstance(instanceId);
+ if (msgIds == null || msgIds.isEmpty()) {
+ throw new BusinessException(400, "At least one msgId is required");
+ }
+ if (msgIds.size() > MAX_SELECTED_MESSAGES) {
+ throw new BusinessException(400, "At most 100 msgIds are allowed
per resend");
+ }
+ log.info("Resending selected DLQ messages: group={}, count={},
targetTopic={}",
+ groupName, msgIds.size(), targetTopic);
+ return dlqProvider.resendMessages(instanceId, groupName, msgIds,
targetTopic);
+ }
+
+ public DLQExcelExportResultVO exportExcel(String instanceId, String
groupName, Long startTime, Long endTime,
+ List<String> msgIds) {
+ requireApacheInstance(instanceId);
+ validateResendRequest(groupName, startTime, endTime);
+ if (msgIds != null && msgIds.size() > MAX_SELECTED_MESSAGES) {
+ throw new BusinessException(400, "At most 100 msgIds are allowed
per export");
+ }
+ log.info("Exporting DLQ messages as Excel: group={}, selected={}",
groupName,
+ msgIds == null ? 0 : msgIds.size());
+ return dlqProvider.exportExcel(instanceId, groupName, startTime,
endTime, msgIds);
+ }
+
private void requireApacheInstance(String instanceId) {
providerRegistry.byInstanceId(instanceId).ifPresent(provider -> {
if (provider.vendor() != InstanceVendor.APACHE) {
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 998939600..5f2ec556c 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
@@ -34,8 +34,10 @@ import
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.Pagination;
+import org.apache.rocketmq.studio.instance.dlq.DLQExcelExportResultVO;
import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQMessageExcelRow;
import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
import org.apache.rocketmq.studio.instance.dlq.DLQProvider;
import org.apache.rocketmq.studio.instance.dlq.DLQResendResultVO;
@@ -47,6 +49,7 @@ import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -72,6 +75,7 @@ public class RocketMQDLQProvider implements DLQProvider {
private static final long ONE_HOUR_MILLIS = 3600_000L;
private static final int RESEND_HARD_CAP = 5000;
+ private static final int MAX_PAGE_SIZE = 100;
private static final int MAX_CONSECUTIVE_OFFSET_ILLEGAL = 3;
private static final String ORIGIN_MESSAGE_ID_PROPERTY =
"studio_dlq_origin_message_id";
private static final String ORIGIN_TOPIC_PROPERTY =
"studio_dlq_origin_topic";
@@ -227,6 +231,133 @@ public class RocketMQDLQProvider implements DLQProvider {
.build();
}
+ @Override
+ public DLQResendResultVO resendMessages(String instanceId, String
groupName, List<String> msgIds,
+ String targetTopic) {
+ if (!StringUtils.hasText(groupName)) {
+ throw new BusinessException(400, "groupName is required for DLQ
resend");
+ }
+ if (msgIds == null || msgIds.isEmpty()) {
+ throw new BusinessException(400, "At least one msgId is required
for selected DLQ resend");
+ }
+ groupName = groupName.trim();
+ Set<String> selected = new java.util.HashSet<>();
+ for (String msgId : msgIds) {
+ if (StringUtils.hasText(msgId)) {
+ selected.add(msgId.trim());
+ }
+ }
+ if (selected.isEmpty()) {
+ throw new BusinessException(400, "At least one valid msgId is
required for selected DLQ resend");
+ }
+
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+
+ // Scan a wide window so the selected messages are found regardless of
their store time.
+ long end = System.currentTimeMillis();
+ long begin = end - 7 * 24 * ONE_HOUR_MILLIS;
+ DeadLetterScanResult scanResult =
+ collectDeadLetters(instanceId, dlqTopic, begin, end,
RESEND_HARD_CAP);
+ List<MessageExt> deadLetters = scanResult.messages().stream()
+ .filter(message -> selected.contains(message.getMsgId()))
+ .toList();
+ int[] counts = {0, 0};
+ if (!deadLetters.isEmpty()) {
+ try {
+ runtimeAdminClientResolver.executeProducer(instanceId,
producer -> {
+ for (MessageExt deadLetter : deadLetters) {
+ if (resendOne(producer, deadLetter, targetTopic)) {
+ counts[0]++;
+ } else {
+ counts[1]++;
+ }
+ }
+ return null;
+ });
+ } catch (Exception e) {
+ log.warn("Failed to resend selected dead letters for group {}:
{}", groupName, e.getMessage());
+ counts[1] += deadLetters.size() - counts[0];
+ }
+ }
+ int resent = counts[0];
+ int failed = counts[1];
+ boolean foundAll = deadLetters.size() == selected.size();
+
+ String outcome = classifyOutcome(deadLetters.size(), resent, failed,
!foundAll);
+ String detail = String.format("instanceId=%s, group=%s, selected=%d,
matched=%d, resent=%d, failed=%d",
+ instanceId, groupName, selected.size(), deadLetters.size(),
resent, failed);
+ recordAudit(groupName, detail, outcome);
+ log.info("Selected DLQ resend completed: {}", detail);
+ return DLQResendResultVO.builder()
+ .matched(deadLetters.size())
+ .resent(resent)
+ .failed(failed)
+ .outcome(outcome)
+ .build();
+ }
+
+ @Override
+ public PageResult<DLQMessageVO> listMessages(String instanceId, String
groupName, Long startTime, Long endTime,
+ int page, int pageSize) {
+ if (!StringUtils.hasText(groupName)) {
+ throw new BusinessException(400, "groupName is required for DLQ
message details");
+ }
+ if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+ throw new BusinessException(400, "Invalid page or pageSize");
+ }
+ groupName = groupName.trim();
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+ long end = endTime != null ? endTime : System.currentTimeMillis();
+ long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
+ if (begin >= end) {
+ throw new BusinessException(400, "DLQ detail start time must be
before end time");
+ }
+ List<DLQMessageVO> all = collectDeadLetters(instanceId, dlqTopic,
begin, end, RESEND_HARD_CAP)
+ .messages().stream()
+ .map(this::toExportVO)
+ .toList();
+ long offset = Pagination.pageOffset(page, pageSize);
+ int from = (int) Math.min(offset, all.size());
+ int to = (int) Math.min(offset + pageSize, all.size());
+ return PageResult.of(all.subList(from, to), all.size(), page,
pageSize);
+ }
+
+ @Override
+ public DLQExcelExportResultVO exportExcel(String instanceId, String
groupName, Long startTime, Long endTime,
+ List<String> msgIds) {
+ if (!StringUtils.hasText(groupName)) {
+ throw new BusinessException(400, "groupName is required for DLQ
export");
+ }
+ groupName = groupName.trim();
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+ long end = endTime != null ? endTime : System.currentTimeMillis();
+ long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
+ DeadLetterScanResult scanResult = collectDeadLetters(instanceId,
dlqTopic, begin, end, RESEND_HARD_CAP);
+ Set<String> selected = msgIds == null ? Collections.emptySet()
+ : new java.util.HashSet<>(msgIds);
+ List<DLQMessageVO> messages = scanResult.messages().stream()
+ .filter(message -> selected.isEmpty() ||
selected.contains(message.getMsgId()))
+ .map(this::toExportVO)
+ .toList();
+ byte[] data;
+ try {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ com.alibaba.excel.EasyExcel.write(output, DLQMessageExcelRow.class)
+ .sheet("DLQ")
+
.doWrite(messages.stream().map(DLQMessageExcelRow::from).toList());
+ data = output.toByteArray();
+ } catch (Exception e) {
+ log.warn("Failed to build Excel export for group {}: {}",
groupName, e.getMessage());
+ throw new BusinessException(502, "Failed to export DLQ messages as
Excel: " + e.getMessage());
+ }
+ return DLQExcelExportResultVO.builder()
+ .data(data)
+ .truncated(scanResult.truncated())
+ .failedQueueCount(scanResult.failedQueueCount())
+ .limit(RESEND_HARD_CAP)
+ .build();
+ }
+
@Override
public DLQExportResultVO exportMessages(String instanceId, String
groupName, Long startTime, Long endTime,
Integer maxCount) {
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 a005fcfd8..748913621 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
@@ -327,4 +327,186 @@ class DLQControllerTest {
"unexpected content disposition: " + disposition);
});
}
+
+ @Test
+ void listDLQMessagesShouldReturnPageTest() throws Exception {
+ when(dlqService.listMessages(eq("instance-1"), eq("test-group"),
isNull(), isNull(), eq(1), eq(20)))
+ .thenReturn(PageResult.of(List.of(
+ DLQMessageVO.builder()
+ .msgId("msg-1")
+ .topic("%DLQ%test-group")
+ .queueId(0)
+ .offset(5L)
+ .storeTime(150L)
+ .keys("key-a")
+ .body("hello dlq")
+ .build()), 1, 1, 20));
+
+ mockMvc.perform(get("/api/dlq/test-group/messages")
+ .param("instanceId", "instance-1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.items[0].msgId").value("msg-1"))
+ .andExpect(jsonPath("$.data.total").value(1));
+
+ verify(dlqService).listMessages(eq("instance-1"), eq("test-group"),
isNull(), isNull(), eq(1), eq(20));
+ }
+
+ @Test
+ void listDLQMessagesShouldRejectOversizedPageSizeTest() throws Exception {
+ mockMvc.perform(get("/api/dlq/test-group/messages")
+ .param("instanceId", "instance-1")
+ .param("pageSize", "500"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("pageSize must not
exceed 100"));
+
+ verifyNoInteractions(dlqService);
+ }
+
+ @Test
+ void listDLQMessagesShouldRejectZeroPageTest() throws Exception {
+ mockMvc.perform(get("/api/dlq/test-group/messages")
+ .param("instanceId", "instance-1")
+ .param("page", "0"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("page must be at least
1"));
+
+ verifyNoInteractions(dlqService);
+ }
+
+ @Test
+ void resendSelectedMessagesShouldReturnSuccessTest() throws Exception {
+ Map<String, Object> body = Map.of(
+ "instanceId", "instance-1",
+ "groupName", "test-group",
+ "msgIds", List.of("msg-1", "msg-2"),
+ "targetTopic", "target-topic"
+ );
+
+ mockMvc.perform(post("/api/dlq/resend-selected")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.message").value("success"));
+
+ verify(dlqService).resendSelectedMessages(
+ eq("instance-1"), eq("test-group"), eq(List.of("msg-1",
"msg-2")), eq("target-topic"));
+ }
+
+ @Test
+ void resendSelectedMessagesShouldRejectNullRequestBodyTest() throws
Exception {
+ mockMvc.perform(post("/api/dlq/resend-selected")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("null"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("DLQ resend request is
required"));
+
+ verifyNoInteractions(dlqService);
+ }
+
+ @Test
+ void resendSelectedMessagesShouldRejectEmptyMsgIdsTest() throws Exception {
+ Map<String, Object> body = Map.of(
+ "instanceId", "instance-1",
+ "groupName", "test-group",
+ "msgIds", List.of()
+ );
+
+ mockMvc.perform(post("/api/dlq/resend-selected")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("At least one msgId is
required"));
+
+ verifyNoInteractions(dlqService);
+ }
+
+ @Test
+ void resendSelectedMessagesShouldRejectMoreThanHundredMsgIdsTest() throws
Exception {
+ List<String> msgIds = new java.util.ArrayList<>();
+ for (int i = 0; i < 101; i++) {
+ msgIds.add("msg-" + i);
+ }
+ Map<String, Object> body = Map.of(
+ "instanceId", "instance-1",
+ "groupName", "test-group",
+ "msgIds", msgIds
+ );
+
+ mockMvc.perform(post("/api/dlq/resend-selected")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("At most 100 msgIds are
allowed per resend"));
+
+ verifyNoInteractions(dlqService);
+ }
+
+ @Test
+ void exportDLQExcelShouldReturnAttachmentWithCompletenessHeadersTest()
throws Exception {
+ when(dlqService.exportExcel(eq("instance-1"), eq("test-group"),
isNull(), isNull(), isNull()))
+ .thenReturn(DLQExcelExportResultVO.builder()
+ .data(new byte[]{1, 2, 3})
+ .truncated(false)
+ .failedQueueCount(0)
+ .limit(5000)
+ .build());
+
+ mockMvc.perform(get("/api/dlq/export-excel")
+ .param("instanceId", "instance-1")
+ .param("groupName", "test-group"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"dlq-test-group.xlsx\""))
+ .andExpect(header().string("X-DLQ-Export-Truncated", "false"))
+ .andExpect(header().string("X-DLQ-Export-FailedQueues", "0"))
+ .andExpect(header().string("X-DLQ-Export-Limit", "5000"))
+ .andExpect(content().contentTypeCompatibleWith(
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .andExpect(content().bytes(new byte[]{1, 2, 3}));
+
+ verify(dlqService).exportExcel(eq("instance-1"), eq("test-group"),
isNull(), isNull(), isNull());
+ }
+
+ @Test
+ void exportDLQExcelShouldSanitizeHeaderUnsafeGroupNameCharactersTest()
throws Exception {
+ when(dlqService.exportExcel(eq("instance-1"), eq("we\"ird\\group"),
isNull(), isNull(), isNull()))
+ .thenReturn(DLQExcelExportResultVO.builder()
+ .data(new byte[]{9})
+ .truncated(false)
+ .failedQueueCount(0)
+ .limit(5000)
+ .build());
+
+ mockMvc.perform(get("/api/dlq/export-excel")
+ .param("instanceId", "instance-1")
+ .param("groupName", "we\"ird\\group"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"dlq-we_ird_group.xlsx\""));
+ }
+
+ @Test
+ void exportDLQExcelShouldRejectMoreThanHundredMsgIdsTest() throws
Exception {
+
org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
request =
+ get("/api/dlq/export-excel")
+ .param("instanceId", "instance-1")
+ .param("groupName", "test-group");
+ for (int i = 0; i < 101; i++) {
+ request = request.param("msgIds", "msg-" + i);
+ }
+
+ mockMvc.perform(request)
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("At most 100 msgIds are
allowed per export"));
+
+ verifyNoInteractions(dlqService);
+ }
}
diff --git a/web/src/api/dlq.test.ts b/web/src/api/dlq.test.ts
index 0a2c8c464..efe892d0f 100644
--- a/web/src/api/dlq.test.ts
+++ b/web/src/api/dlq.test.ts
@@ -16,9 +16,10 @@
*/
import MockAdapter from 'axios-mock-adapter';
+import type { InternalAxiosRequestConfig } from 'axios';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { exportDLQMessages, listDLQGroups, resendDLQ } from './message';
+import { exportDLQExcel, exportDLQMessages, listDLQGroups, resendDLQ } from
'./message';
import type { DLQGroup } from './message';
const mock = new MockAdapter(client);
@@ -122,4 +123,57 @@ describe('DLQ API', () => {
expect(meta).toEqual({ truncated: false, failedQueueCount: 0, limit: 0 });
});
+
+ it('serializes selected msgIds as repeated params so Spring binds them',
async () => {
+ // axios's default serializer emits `msgIds[]=a&msgIds[]=b`, which Spring's
+ // @RequestParam List<String> does not bind — the backend would silently
fall
+ // back to exporting the whole time window. Capture the request config that
+ // exportDLQExcel actually sends and lock its repeated-param serialization.
+ let capturedConfig: InternalAxiosRequestConfig | null = null;
+ const originalAdapter = client.defaults.adapter;
+ client.defaults.adapter = (config) => {
+ capturedConfig = config;
+ return Promise.resolve({
+ data: new Blob(['xlsx']),
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config,
+ } as never);
+ };
+ try {
+ await exportDLQExcel({
+ instanceId: 'instance-1',
+ groupName: group.groupName,
+ msgIds: ['msg-1', 'msg-2'],
+ });
+ } finally {
+ client.defaults.adapter = originalAdapter;
+ }
+ expect(capturedConfig).not.toBeNull();
+ const serialized = client.getUri(capturedConfig as never);
+ expect(serialized).toContain('msgIds=msg-1&msgIds=msg-2');
+ expect(serialized).not.toContain('msgIds[]');
+ });
+
+ it('returns the Excel export blob with completeness metadata from the
response headers', async () => {
+ const payload = new Blob(['xlsx'], {
+ type:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+ mock.onGet('/dlq/export-excel').reply(200, payload, {
+ 'content-type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'content-disposition': 'attachment; filename="dlq-order-consumer.xlsx"',
+ 'x-dlq-export-truncated': 'true',
+ 'x-dlq-export-failedqueues': '2',
+ 'x-dlq-export-limit': '5000',
+ });
+
+ const { blob, meta } = await exportDLQExcel({
+ instanceId: 'instance-1',
+ groupName: group.groupName,
+ });
+
+ await expect(blob.text()).resolves.toBe('xlsx');
+ expect(meta).toEqual({ truncated: true, failedQueueCount: 2, limit: 5000
});
+ });
});
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index e5b705674..81df854a3 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -92,6 +92,24 @@ export interface DLQResendResult {
failedQueueCount?: number;
}
+export interface DLQMessage {
+ msgId: string;
+ topic: string;
+ queueId: number;
+ offset: number;
+ storeTime: number;
+ keys: string | null;
+ body: string | null;
+ bodyBase64: string | null;
+}
+
+export interface DLQMessagePage {
+ items: DLQMessage[];
+ total: number;
+ page: number;
+ size: number;
+}
+
// ─── Messages ───────────────────────────────────────────────────
export async function queryMessages(params: MessageQuery) {
const res = await client.get<{ data: MessageRecord[] }>('/messages', {
params });
@@ -185,3 +203,54 @@ export async function pullMessageAtOffset(params: {
});
return res.data.data;
}
+
+export async function listDLQMessages(params: {
+ instanceId: string;
+ groupName: string;
+ startTime?: number;
+ endTime?: number;
+ page?: number;
+ pageSize?: number;
+}): Promise<DLQMessagePage> {
+ const res = await client.get<{ data: DLQMessagePage }>(
+ `/dlq/${encodeURIComponent(params.groupName)}/messages`,
+ { params },
+ );
+ return res.data.data;
+}
+
+export async function resendDLQSelected(data: {
+ instanceId: string;
+ groupName: string;
+ msgIds: string[];
+ targetTopic?: string;
+}): Promise<DLQResendResult> {
+ const res = await client.post<{ data: DLQResendResult
}>('/dlq/resend-selected', data);
+ return res.data.data;
+}
+
+export async function exportDLQExcel(params: {
+ instanceId: string;
+ groupName: string;
+ startTime?: number;
+ endTime?: number;
+ msgIds?: string[];
+}): Promise<{ blob: Blob; meta: DLQExportMeta }> {
+ const res = await client.get<Blob>('/dlq/export-excel', {
+ params,
+ responseType: 'blob',
+ // Repeat the parameter name per item (`msgIds=a&msgIds=b`) instead of
axios's default
+ // bracketed form (`msgIds[]=a`), which Spring's @RequestParam
List<String> would not
+ // bind — the request would silently fall back to exporting the whole time
window.
+ paramsSerializer: { indexes: null },
+ });
+ const header = (name: string): string => String(res.headers[name] ?? '');
+ return {
+ blob: res.data,
+ meta: {
+ truncated: header('x-dlq-export-truncated') === 'true',
+ failedQueueCount: Number.parseInt(header('x-dlq-export-failedqueues'),
10) || 0,
+ limit: Number.parseInt(header('x-dlq-export-limit'), 10) || 0,
+ },
+ };
+}
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 02a578a1e..55860b563 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -21,7 +21,7 @@ import userEvent from '@testing-library/user-event';
import type React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
-import type { DLQGroup, DLQGroupPage, DLQResendResult } from
'../../../api/message';
+import type { DLQGroup, DLQGroupPage, DLQMessagePage, DLQResendResult } from
'../../../api/message';
import { LangProvider } from '../../../i18n/LangContext';
import * as messageService from '../../../services/messageService';
import DLQPage from '../dlq';
@@ -30,6 +30,9 @@ vi.mock('../../../services/messageService', () => ({
listDLQGroups: vi.fn(),
resendDLQ: vi.fn(),
exportDLQMessages: vi.fn(),
+ listDLQMessages: vi.fn(),
+ resendDLQSelected: vi.fn(),
+ exportDLQExcel: vi.fn(),
}));
vi.mock('../../../services/instanceService', () => ({
listInstances: vi.fn().mockResolvedValue([
@@ -124,6 +127,9 @@ describe('DLQ page', () => {
vi.mocked(messageService.listDLQGroups).mockReset();
vi.mocked(messageService.resendDLQ).mockReset();
vi.mocked(messageService.exportDLQMessages).mockReset();
+ vi.mocked(messageService.listDLQMessages).mockReset();
+ vi.mocked(messageService.resendDLQSelected).mockReset();
+ vi.mocked(messageService.exportDLQExcel).mockReset();
createObjectURL = vi.fn().mockReturnValue('blob:dlq');
revokeObjectURL = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
@@ -136,6 +142,12 @@ describe('DLQ page', () => {
});
clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(() => {});
vi.mocked(messageService.listDLQGroups).mockResolvedValue(pageOf([dlqGroup]));
+ vi.mocked(messageService.listDLQMessages).mockResolvedValue({
+ items: [],
+ total: 0,
+ page: 1,
+ size: 20,
+ } satisfies DLQMessagePage);
});
afterEach(() => {
@@ -227,23 +239,24 @@ describe('DLQ page', () => {
expect(screen.getByText('-')).toBeInTheDocument();
});
- it('opens a detail dialog with the selected group metadata', async () => {
+ it('opens a message detail drawer with the selected group metadata', async
() => {
const user = userEvent.setup();
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
- await user.click(screen.getByRole('button', { name: /查看详情/ }));
+ await user.click(screen.getByRole('button', { name: /消息明细/ }));
- expect(await screen.findByText('死信队列详情')).toBeInTheDocument();
- expect(screen.getAllByText('cg-order')).toHaveLength(2);
- expect(screen.getAllByText('%DLQ%cg-order')).toHaveLength(2);
- expect(screen.getByText('ACTIVE')).toBeInTheDocument();
+ expect(await screen.findByText('DLQ 消息明细 · cg-order')).toBeInTheDocument();
+
expect(screen.getAllByText('%DLQ%cg-order').length).toBeGreaterThanOrEqual(2);
+ expect(messageService.listDLQMessages).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-1', groupName:
'cg-order' }),
+ );
});
- it('exports the dead-letter messages of a group as JSON', async () => {
- vi.mocked(messageService.exportDLQMessages).mockResolvedValue({
- blob: new
Blob(['[{"msgId":"m1","topic":"%DLQ%cg-order","queueId":0,"offset":5}]'], {
- type: 'application/json',
+ it('exports the dead-letter messages of a group as Excel', async () => {
+ vi.mocked(messageService.exportDLQExcel).mockResolvedValue({
+ blob: new Blob(['xlsx-bytes'], {
+ type:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
});
@@ -253,8 +266,8 @@ describe('DLQ page', () => {
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(messageService.exportDLQExcel).toHaveBeenCalledTimes(1);
+ const exportParams =
vi.mocked(messageService.exportDLQExcel).mock.calls[0][0];
expect(exportParams.instanceId).toBe('instance-1');
expect(exportParams.groupName).toBe('cg-order');
expect(typeof exportParams.startTime).toBe('number');
@@ -262,15 +275,15 @@ describe('DLQ page', () => {
// 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('"msgId":"m1"');
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
});
it('warns when the export scan is incomplete', async () => {
- vi.mocked(messageService.exportDLQMessages).mockResolvedValue({
- blob: new Blob(['[]'], { type: 'application/json' }),
+ vi.mocked(messageService.exportDLQExcel).mockResolvedValue({
+ blob: new Blob([''], {
+ type:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ }),
meta: { truncated: true, failedQueueCount: 2, limit: 100 },
});
const user = userEvent.setup();
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index d18319d97..919e8924e 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -25,6 +25,7 @@ import {
Space,
Flex,
Modal,
+ Drawer,
DatePicker,
Typography,
message,
@@ -34,10 +35,17 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
+import InfoBanner from '../../components/InfoBanner';
import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
-import type { DLQGroup } from '../../api/message';
-import { exportDLQMessages, listDLQGroups, resendDLQ } from
'../../services/messageService';
+import type { DLQGroup, DLQMessage } from '../../api/message';
+import {
+ exportDLQExcel,
+ listDLQGroups,
+ listDLQMessages,
+ resendDLQ,
+ resendDLQSelected,
+} from '../../services/messageService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { buildCsv, downloadBlob, downloadCsv, type CsvColumn } from
'../../utils/download';
import { tableScrollX } from '../../utils/table';
@@ -123,11 +131,22 @@ const DLQPage = () => {
const [selectedGroupNames, setSelectedGroupNames] = useState<string[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [retryError, setRetryError] = useState<string | null>(null);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [detailMessages, setDetailMessages] = useState<DLQMessage[]>([]);
+ const [detailTotal, setDetailTotal] = useState(0);
+ const [detailPage, setDetailPage] = useState(1);
+ const [detailPageSize, setDetailPageSize] = useState(20);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailSelectedMsgIds, setDetailSelectedMsgIds] =
useState<string[]>([]);
+ const [detailResending, setDetailResending] = useState(false);
+ const [detailError, setDetailError] = useState<string | null>(null);
+ const detailRequestIdRef = useRef(0);
const retryRequestIdRef = useRef(0);
useEffect(
() => () => {
retryRequestIdRef.current += 1;
+ detailRequestIdRef.current += 1;
},
[],
);
@@ -266,13 +285,13 @@ const DLQPage = () => {
const handleExport = async (group: DLQGroup) => {
try {
- const { blob, meta } = await exportDLQMessages({
+ const { blob, meta } = await exportDLQExcel({
instanceId: selectedInstanceId,
groupName: group.groupName,
startTime: exportRange[0].valueOf(),
endTime: exportRange[1].valueOf(),
});
- downloadBlob(blob, `${group.groupName}-dlq-messages.json`);
+ downloadBlob(blob, `${group.groupName}-dlq-messages.xlsx`);
if (meta.truncated || meta.failedQueueCount > 0) {
message.warning(
`导出可能不完整:${meta.failedQueueCount} 个队列无法扫描,导出上限 ${meta.limit} 条`,
@@ -290,6 +309,97 @@ const DLQPage = () => {
exportDLQGroups(selectedGroups, 'dlq-groups.csv');
};
+ /* ─── DLQ Message Details Drawer ─── */
+ const openDetailDrawer = (group: DLQGroup) => {
+ setDetailGroup(group);
+ setDetailOpen(true);
+ setDetailPage(1);
+ setDetailSelectedMsgIds([]);
+ setDetailError(null);
+ void loadDetailMessages(group, 1, detailPageSize);
+ };
+
+ const loadDetailMessages = async (group: DLQGroup, page: number, pageSize:
number) => {
+ if (!selectedInstanceId) return;
+ const requestId = detailRequestIdRef.current + 1;
+ detailRequestIdRef.current = requestId;
+ setDetailLoading(true);
+ setDetailError(null);
+ try {
+ const result = await listDLQMessages({
+ instanceId: selectedInstanceId,
+ groupName: group.groupName,
+ startTime: exportRange[0].valueOf(),
+ endTime: exportRange[1].valueOf(),
+ page,
+ pageSize,
+ });
+ if (detailRequestIdRef.current !== requestId) return;
+ setDetailMessages(result.items);
+ setDetailTotal(result.total);
+ setDetailPage(page);
+ } catch (error) {
+ if (detailRequestIdRef.current === requestId) {
+ setDetailError(getErrorMessage(error, '死信消息明细加载失败,请稍后重试'));
+ }
+ } finally {
+ if (detailRequestIdRef.current === requestId) {
+ setDetailLoading(false);
+ }
+ }
+ };
+
+ const resendSelectedMessages = async (msgIds: string[]) => {
+ if (!selectedInstanceId || !detailGroup || msgIds.length === 0) return;
+ setDetailResending(true);
+ setDetailError(null);
+ try {
+ const result = await resendDLQSelected({
+ instanceId: selectedInstanceId,
+ groupName: detailGroup.groupName,
+ msgIds,
+ });
+ if (result.outcome === 'FAILED' && result.failed > 0) {
+ message.error(`重发失败:成功 ${result.resent},失败 ${result.failed}`);
+ } else if (result.resent > 0 && result.failed > 0) {
+ message.warning(`重发部分完成:成功 ${result.resent},失败 ${result.failed}`);
+ } else {
+ message.success(`重发完成:成功 ${result.resent} 条`);
+ }
+ setDetailSelectedMsgIds([]);
+ await loadDetailMessages(detailGroup, detailPage, detailPageSize);
+ } catch (error) {
+ setDetailError(getErrorMessage(error, '重发死信消息失败,请稍后重试'));
+ } finally {
+ setDetailResending(false);
+ }
+ };
+
+ const exportDetailExcel = async () => {
+ if (!selectedInstanceId || !detailGroup) return;
+ try {
+ const { blob, meta } = await exportDLQExcel({
+ instanceId: selectedInstanceId,
+ groupName: detailGroup.groupName,
+ startTime: exportRange[0].valueOf(),
+ endTime: exportRange[1].valueOf(),
+ msgIds: detailSelectedMsgIds.length > 0 ? detailSelectedMsgIds :
undefined,
+ });
+ downloadBlob(blob, `${detailGroup.groupName}-dlq-messages.xlsx`);
+ if (meta.truncated || meta.failedQueueCount > 0) {
+ message.warning(
+ `导出可能不完整:${meta.failedQueueCount} 个队列无法扫描,导出上限 ${meta.limit} 条`,
+ );
+ } else {
+ message.success(
+ `已导出 ${detailSelectedMsgIds.length > 0 ? `选中的
${detailSelectedMsgIds.length} 条` : '全部'}死信消息(${blob.size} 字节)`,
+ );
+ }
+ } catch (error) {
+ message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
+ }
+ };
+
/* ─── Table Columns ─── */
const columns: ColumnsType<DLQGroup> = [
{
@@ -361,9 +471,9 @@ const DLQPage = () => {
size="small"
icon={<Eye size={14} />}
style={{ borderColor: '#1677ff', color: '#1677ff' }}
- onClick={() => setDetailGroup(record)}
+ onClick={() => openDetailDrawer(record)}
>
- 查看详情
+ 消息明细
</Button>
<Button
size="small"
@@ -388,6 +498,82 @@ const DLQPage = () => {
},
];
+ /* ─── Detail Drawer Columns ─── */
+ const detailColumns: ColumnsType<DLQMessage> = [
+ {
+ title: 'Message ID',
+ dataIndex: 'msgId',
+ key: 'msgId',
+ width: 240,
+ render: (msgId: string) => (
+ <Text copyable style={{ fontFamily: 'monospace', fontSize: 14 }}>
+ {msgId}
+ </Text>
+ ),
+ },
+ {
+ title: 'Queue',
+ key: 'queue',
+ width: 90,
+ render: (_: unknown, record: DLQMessage) => (
+ <Text style={{ fontFamily: 'monospace' }}>{record.queueId}</Text>
+ ),
+ },
+ {
+ title: 'Offset',
+ dataIndex: 'offset',
+ key: 'offset',
+ width: 90,
+ render: (offset: number) => <Text style={{ fontFamily: 'monospace'
}}>{offset}</Text>,
+ },
+ {
+ title: '入队时间',
+ dataIndex: 'storeTime',
+ key: 'storeTime',
+ width: 160,
+ render: (storeTime: number) => (
+ <Text style={{ fontFamily: 'monospace', fontSize: 14 }}>
+ {formatDateTime(new Date(storeTime).toISOString())}
+ </Text>
+ ),
+ },
+ {
+ title: 'Keys',
+ dataIndex: 'keys',
+ key: 'keys',
+ width: 140,
+ ellipsis: true,
+ render: (keys: string | null) => keys ?? '-',
+ },
+ {
+ title: 'Body',
+ dataIndex: 'body',
+ key: 'body',
+ width: 220,
+ ellipsis: true,
+ render: (body: string | null) => (
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ {body && body.length > 80 ? `${body.slice(0, 80)}…` : body ?? '-'}
+ </Text>
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 100,
+ render: (_: unknown, record: DLQMessage) => (
+ <Button
+ size="small"
+ icon={<ArrowsCounterClockwise size={13} />}
+ loading={detailResending}
+ onClick={() => void resendSelectedMessages([record.msgId])}
+ >
+ 重发
+ </Button>
+ ),
+ },
+ ];
+
/* ═══════════════════════════════════════════
Render
═══════════════════════════════════════════ */
@@ -575,72 +761,123 @@ const DLQPage = () => {
)}
</Modal>
- <Modal
- title="死信队列详情"
- open={Boolean(detailGroup)}
- onCancel={() => setDetailGroup(null)}
- footer={<Button onClick={() => setDetailGroup(null)}>关闭</Button>}
- width={560}
+ {/* ═══════════════════════════════════════════
+ Message Detail Drawer
+ ═══════════════════════════════════════════ */}
+ <Drawer
+ title={detailGroup ? `DLQ 消息明细 · ${detailGroup.groupName}` : 'DLQ
消息明细'}
+ width={1080}
+ open={detailOpen}
+ onClose={() => {
+ detailRequestIdRef.current += 1;
+ setDetailOpen(false);
+ setDetailGroup(null);
+ setDetailMessages([]);
+ setDetailSelectedMsgIds([]);
+ setDetailError(null);
+ }}
destroyOnHidden
>
{detailGroup && (
- <div style={{ marginTop: 8 }}>
- <div style={{ marginBottom: 16 }}>
- <Text type="secondary" style={{ fontSize: 14, display: 'block',
marginBottom: 4 }}>
- Group 名称
- </Text>
- <Text strong copyable style={{ fontSize: 14, fontFamily:
'monospace' }}>
- {detailGroup.groupName}
- </Text>
- </div>
- <div style={{ marginBottom: 16 }}>
- <Text type="secondary" style={{ fontSize: 14, display: 'block',
marginBottom: 4 }}>
- DLQ Topic
- </Text>
- <Text copyable style={{ fontSize: 14, fontFamily: 'monospace' }}>
- {detailGroup.dlqTopic}
- </Text>
- </div>
- <Flex gap={24} wrap="wrap">
- <div>
- <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
- 死信数量
- </Text>
- <Text
- strong
- style={{ color: detailGroup.messageCount > 0 ? '#fa8c16' :
undefined }}
- >
- {detailGroup.statsAvailable === false
- ? '不可用'
- : detailGroup.messageCount.toLocaleString()}
- </Text>
- </div>
- <div>
- <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
- 重试次数
- </Text>
- <Text>{detailGroup.retryCount.toLocaleString()}</Text>
- </div>
- <div>
- <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
- 状态
- </Text>
- <Text>
- {detailGroup.statsAvailable === false ? '统计不可用' :
detailGroup.status}
- </Text>
- </div>
+ <>
+ <Flex
+ justify="space-between"
+ align="flex-start"
+ wrap="wrap"
+ gap={12}
+ style={{ marginBottom: 16 }}
+ >
+ <Space size={24} wrap>
+ <div>
+ <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
+ DLQ Topic
+ </Text>
+ <Text copyable style={{ fontFamily: 'monospace' }}>
+ {detailGroup.dlqTopic}
+ </Text>
+ </div>
+ <div>
+ <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
+ 死信数量
+ </Text>
+ <Text strong style={{ color: detailGroup.messageCount > 0 ?
'#fa8c16' : undefined }}>
+ {detailGroup.statsAvailable === false
+ ? '不可用'
+ : detailGroup.messageCount.toLocaleString()}
+ </Text>
+ </div>
+ <div>
+ <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
+ 最近入队时间
+ </Text>
+ <Text style={{ fontFamily: 'monospace' }}>
+ {formatDateTime(detailGroup.lastEnqueueTime)}
+ </Text>
+ </div>
+ </Space>
+ <Button
+ icon={<Download size={15} />}
+ disabled={detailTotal === 0}
+ onClick={() => void exportDetailExcel()}
+ >
+ {detailSelectedMsgIds.length > 0
+ ? `导出选中 (${detailSelectedMsgIds.length})`
+ : '导出全部'}
+ </Button>
</Flex>
- <div style={{ marginTop: 16 }}>
- <Text type="secondary" style={{ fontSize: 14, display: 'block',
marginBottom: 4 }}>
- 最近入队时间
- </Text>
- <Text style={{ fontFamily: 'monospace' }}>
- {formatDateTime(detailGroup.lastEnqueueTime)}
- </Text>
- </div>
- </div>
+
+ <InfoBanner
+ description={`明细按「导出时间范围」查询(${exportRange[0].format('YYYY-MM-DD
HH:mm:ss')} ~ ${exportRange[1].format('YYYY-MM-DD HH:mm:ss')})。勾选后可单条或批量重发、导出
Excel。`}
+ />
+
+ {detailError && (
+ <Alert showIcon type="warning" message={detailError} style={{
marginBottom: 16 }} />
+ )}
+
+ <Table<DLQMessage>
+ rowKey="msgId"
+ size="small"
+ loading={detailLoading}
+ dataSource={detailMessages}
+ rowSelection={{
+ selectedRowKeys: detailSelectedMsgIds,
+ onChange: (keys) => setDetailSelectedMsgIds(keys.map(String)),
+ }}
+ pagination={{
+ current: detailPage,
+ pageSize: detailPageSize,
+ total: detailTotal,
+ showSizeChanger: true,
+ pageSizeOptions: [10, 20, 50],
+ showTotal: (totalCount) => `共 ${totalCount} 条消息`,
+ onChange: (nextPage, nextPageSize) => {
+ setDetailPage(nextPage);
+ setDetailPageSize(nextPageSize);
+ setDetailSelectedMsgIds([]);
+ if (detailGroup) {
+ void loadDetailMessages(detailGroup, nextPage,
nextPageSize);
+ }
+ },
+ }}
+ scroll={{ x: tableScrollX(detailColumns, { selection: true }) }}
+ columns={detailColumns}
+ />
+
+ {detailSelectedMsgIds.length > 0 && (
+ <Flex justify="flex-end" style={{ marginTop: 16 }}>
+ <Button
+ type="primary"
+ icon={<ArrowsCounterClockwise size={15} />}
+ loading={detailResending}
+ onClick={() => void
resendSelectedMessages(detailSelectedMsgIds)}
+ >
+ 批量重发选中 ({detailSelectedMsgIds.length})
+ </Button>
+ </Flex>
+ )}
+ </>
)}
- </Modal>
+ </Drawer>
</div>
);
};
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index 776fc3a64..a65aac08e 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -8,6 +8,7 @@ import type {
TraceRecord,
DLQGroup,
DLQGroupPage,
+ DLQMessagePage,
DLQResendResult,
DLQExportMeta,
} from '../api/message';
@@ -129,3 +130,47 @@ export async function exportDLQMessages(params: {
}
return messageApi.exportDLQMessages(params);
}
+
+export async function listDLQMessages(params: {
+ instanceId: string;
+ groupName: string;
+ startTime?: number;
+ endTime?: number;
+ page?: number;
+ pageSize?: number;
+}): Promise<DLQMessagePage> {
+ if (isMockMode()) {
+ return { items: [], total: 0, page: params.page ?? 1, size:
params.pageSize ?? 20 };
+ }
+ return messageApi.listDLQMessages(params);
+}
+
+export async function resendDLQSelected(data: {
+ instanceId: string;
+ groupName: string;
+ msgIds: string[];
+ targetTopic?: string;
+}): Promise<DLQResendResult> {
+ if (isMockMode()) {
+ return { matched: data.msgIds.length, resent: data.msgIds.length, failed:
0, outcome: 'SUCCESS' };
+ }
+ return messageApi.resendDLQSelected(data);
+}
+
+export async function exportDLQExcel(params: {
+ instanceId: string;
+ groupName: string;
+ startTime?: number;
+ endTime?: number;
+ msgIds?: string[];
+}): Promise<{ blob: Blob; meta: DLQExportMeta }> {
+ if (isMockMode()) {
+ return {
+ blob: new Blob([''], {
+ type:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ }),
+ meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
+ };
+ }
+ return messageApi.exportDLQExcel(params);
+}