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 51c36d71 feat: complete DLQ data and actions (#640)
51c36d71 is described below

commit 51c36d71f41463868ce757ae0ac135d5ac317a1b
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 07:22:58 2026 -0700

    feat: complete DLQ data and actions (#640)
---
 .../studio/instance/dlq/DLQProviderStub.java       |  52 ++++++++-
 .../studio/instance/dlq/DLQProviderStubTest.java   |  69 ++++++++++++
 web/src/pages/instance/__tests__/DLQPage.test.tsx  | 124 +++++++++++++++++++++
 web/src/pages/instance/dlq.tsx                     |  89 ++++++++++++++-
 4 files changed, 329 insertions(+), 5 deletions(-)

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 2d4c269a..4dce32f4 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
@@ -18,22 +18,68 @@ package org.apache.rocketmq.studio.instance.dlq;
 
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
 
-import java.util.Collections;
+import java.time.LocalDateTime;
 import java.util.List;
 
 @Component
 @Slf4j
 public class DLQProviderStub implements DLQProvider {
 
+    private final List<StubDLQGroup> stubData = List.of(
+            new StubDLQGroup("rmq-cn-v5-prod-01", DLQGroupVO.builder()
+                    .groupName("cg-order-payment")
+                    .dlqTopic("%DLQ%cg-order-payment")
+                    .messageCount(128)
+                    .lastEnqueueTime(LocalDateTime.of(2026, 7, 24, 10, 15, 30))
+                    .retryCount(16)
+                    .status("ACTIVE")
+                    .build()),
+            new StubDLQGroup("rmq-cn-v5-prod-01", DLQGroupVO.builder()
+                    .groupName("cg-inventory-sync")
+                    .dlqTopic("%DLQ%cg-inventory-sync")
+                    .messageCount(24)
+                    .lastEnqueueTime(LocalDateTime.of(2026, 7, 24, 9, 40, 12))
+                    .retryCount(8)
+                    .status("ACTIVE")
+                    .build()),
+            new StubDLQGroup("rmq-cn-v4-prod-02", DLQGroupVO.builder()
+                    .groupName("legacy-order-consumer")
+                    .dlqTopic("%DLQ%legacy-order-consumer")
+                    .messageCount(6)
+                    .lastEnqueueTime(LocalDateTime.of(2026, 7, 23, 22, 5, 0))
+                    .retryCount(3)
+                    .status("ACKED")
+                    .build())
+    );
+
     @Override
     public List<DLQGroupVO> listDLQGroups(String clusterId) {
-        log.warn("DLQProviderStub.listDLQGroups called - returning empty 
list");
-        return Collections.emptyList();
+        log.info("DLQProviderStub.listDLQGroups called. clusterId={}", 
clusterId);
+        return stubData.stream()
+                .filter(item -> !StringUtils.hasText(clusterId) || 
item.clusterId().equals(clusterId))
+                .map(StubDLQGroup::group)
+                .map(DLQProviderStub::copyGroup)
+                .toList();
     }
 
     @Override
     public void resendMessages(String groupName, Long startTime, Long endTime, 
String targetTopic) {
         log.warn("DLQProviderStub.resendMessages called - no-op. group={}, 
targetTopic={}", groupName, targetTopic);
     }
+
+    private static DLQGroupVO copyGroup(DLQGroupVO group) {
+        return DLQGroupVO.builder()
+                .groupName(group.getGroupName())
+                .dlqTopic(group.getDlqTopic())
+                .messageCount(group.getMessageCount())
+                .lastEnqueueTime(group.getLastEnqueueTime())
+                .retryCount(group.getRetryCount())
+                .status(group.getStatus())
+                .build();
+    }
+
+    private record StubDLQGroup(String clusterId, DLQGroupVO group) {
+    }
 }
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
new file mode 100644
index 00000000..44a59a86
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DLQProviderStubTest {
+
+    private final DLQProviderStub provider = new DLQProviderStub();
+
+    @Test
+    void listDLQGroupsShouldReturnSampleGroupsForAllClusters() {
+        List<DLQGroupVO> groups = provider.listDLQGroups(null);
+
+        assertThat(groups)
+                .extracting(DLQGroupVO::getGroupName)
+                .containsExactly("cg-order-payment", "cg-inventory-sync", 
"legacy-order-consumer");
+        assertThat(groups)
+                .allSatisfy(group -> {
+                    assertThat(group.getDlqTopic()).startsWith("%DLQ%");
+                    assertThat(group.getMessageCount()).isPositive();
+                    assertThat(group.getLastEnqueueTime()).isNotNull();
+                    assertThat(group.getStatus()).isNotBlank();
+                });
+    }
+
+    @Test
+    void listDLQGroupsShouldFilterByClusterId() {
+        List<DLQGroupVO> groups = provider.listDLQGroups("rmq-cn-v5-prod-01");
+
+        assertThat(groups)
+                .extracting(DLQGroupVO::getGroupName)
+                .containsExactly("cg-order-payment", "cg-inventory-sync");
+    }
+
+    @Test
+    void listDLQGroupsShouldReturnEmptyForUnknownCluster() {
+        assertThat(provider.listDLQGroups("missing-cluster")).isEmpty();
+    }
+
+    @Test
+    void listDLQGroupsShouldReturnDefensiveCopies() {
+        List<DLQGroupVO> firstRead = 
provider.listDLQGroups("rmq-cn-v5-prod-01");
+        firstRead.get(0).setGroupName("mutated");
+
+        List<DLQGroupVO> secondRead = 
provider.listDLQGroups("rmq-cn-v5-prod-01");
+
+        
assertThat(secondRead.get(0).getGroupName()).isEqualTo("cg-order-payment");
+    }
+}
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
new file mode 100644
index 00000000..b1054073
--- /dev/null
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
+import type { DLQGroup } from '../../../api/message';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as messageService from '../../../services/messageService';
+import DLQPage from '../dlq';
+
+vi.mock('../../../services/messageService', () => ({
+  listDLQGroups: vi.fn(),
+  resendDLQ: vi.fn(),
+}));
+
+const dlqGroup: DLQGroup = {
+  groupName: 'cg-order',
+  dlqTopic: '%DLQ%cg-order',
+  messageCount: 7,
+  lastEnqueueTime: '2026-07-24T10:00:00Z',
+  retryCount: 3,
+  status: 'ACTIVE',
+};
+
+const renderWithProviders = (ui: React.ReactElement) =>
+  render(
+    <App>
+      <LangProvider>{ui}</LangProvider>
+    </App>,
+  );
+
+describe('DLQ page', () => {
+  let createObjectURL: ReturnType<typeof vi.fn>;
+  let revokeObjectURL: ReturnType<typeof vi.fn>;
+  let clickSpy: ReturnType<typeof vi.spyOn>;
+
+  beforeAll(() => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      value: vi.fn().mockImplementation((query: string) => ({
+        matches: false,
+        media: query,
+        onchange: null,
+        addListener: vi.fn(),
+        removeListener: vi.fn(),
+        addEventListener: vi.fn(),
+        removeEventListener: vi.fn(),
+        dispatchEvent: vi.fn(),
+      })),
+    });
+  });
+
+  beforeEach(() => {
+    createObjectURL = vi.fn().mockReturnValue('blob:dlq');
+    revokeObjectURL = vi.fn();
+    Object.defineProperty(URL, 'createObjectURL', {
+      configurable: true,
+      value: createObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      configurable: true,
+      value: revokeObjectURL,
+    });
+    clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(() => {});
+    vi.mocked(messageService.listDLQGroups).mockResolvedValue([dlqGroup]);
+  });
+
+  afterEach(() => {
+    clickSpy.mockRestore();
+    vi.clearAllMocks();
+  });
+
+  it('loads DLQ groups through the service layer', async () => {
+    renderWithProviders(<DLQPage />);
+
+    expect(await screen.findByText('cg-order')).toBeInTheDocument();
+    expect(screen.getByText('%DLQ%cg-order')).toBeInTheDocument();
+    expect(messageService.listDLQGroups).toHaveBeenCalledTimes(1);
+  });
+
+  it('opens a detail dialog with the selected group metadata', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<DLQPage />);
+
+    await screen.findByText('cg-order');
+    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();
+  });
+
+  it('exports the selected group summary as CSV', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<DLQPage />);
+
+    await screen.findByText('cg-order');
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    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"');
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+    expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
+  });
+});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 88a385d6..b8e10c7d 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -65,6 +65,7 @@ const DLQPage = () => {
   ]);
   const [retryTargetTopic, setRetryTargetTopic] = useState('');
   const [retrySubmitting, setRetrySubmitting] = useState(false);
+  const [detailGroup, setDetailGroup] = useState<DLQGroup | null>(null);
 
   useEffect(() => {
     let cancelled = false;
@@ -129,7 +130,28 @@ const DLQPage = () => {
   };
 
   const handleExport = (group: DLQGroup) => {
-    message.success(`已导出 ${group.groupName} 的死信消息(模拟)`);
+    const rows = [
+      ['Group Name', 'DLQ Topic', 'Message Count', 'Retry Count', 'Status', 
'Last Enqueue Time'],
+      [
+        group.groupName,
+        group.dlqTopic,
+        String(group.messageCount),
+        String(group.retryCount),
+        group.status,
+        group.lastEnqueueTime,
+      ],
+    ];
+    const csv = rows
+      .map((row) => row.map((value) => `"${value.replace(/"/g, 
'""')}"`).join(','))
+      .join('\n');
+    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `${group.groupName}-dlq.csv`;
+    a.click();
+    URL.revokeObjectURL(url);
+    message.success(`已导出 ${group.groupName} 的死信队列摘要`);
   };
 
   /* ─── Table Columns ─── */
@@ -196,7 +218,7 @@ const DLQPage = () => {
             size="small"
             icon={<Eye size={14} />}
             style={{ borderColor: '#1677ff', color: '#1677ff' }}
-            onClick={() => message.info(`查看 ${record.groupName} 详情(模拟)`)}
+            onClick={() => setDetailGroup(record)}
           >
             查看详情
           </Button>
@@ -345,6 +367,69 @@ const DLQPage = () => {
           </div>
         )}
       </Modal>
+
+      <Modal
+        title="死信队列详情"
+        open={Boolean(detailGroup)}
+        onCancel={() => setDetailGroup(null)}
+        footer={<Button onClick={() => setDetailGroup(null)}>关闭</Button>}
+        width={560}
+        destroyOnClose
+      >
+        {detailGroup && (
+          <div style={{ marginTop: 8 }}>
+            <div style={{ marginBottom: 16 }}>
+              <Text type="secondary" style={{ fontSize: 13, 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: 13, 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: 13, display: 
'block', marginBottom: 4 }}>
+                  死信数量
+                </Text>
+                <Text
+                  strong
+                  style={{ color: detailGroup.messageCount > 0 ? '#fa8c16' : 
undefined }}
+                >
+                  {detailGroup.messageCount.toLocaleString()}
+                </Text>
+              </div>
+              <div>
+                <Text type="secondary" style={{ fontSize: 13, display: 
'block', marginBottom: 4 }}>
+                  重试次数
+                </Text>
+                <Text>{detailGroup.retryCount.toLocaleString()}</Text>
+              </div>
+              <div>
+                <Text type="secondary" style={{ fontSize: 13, display: 
'block', marginBottom: 4 }}>
+                  状态
+                </Text>
+                <Text>{detailGroup.status}</Text>
+              </div>
+            </Flex>
+            <div style={{ marginTop: 16 }}>
+              <Text type="secondary" style={{ fontSize: 13, display: 'block', 
marginBottom: 4 }}>
+                最近入队时间
+              </Text>
+              <Text style={{ fontFamily: 'monospace' }}>
+                {formatDateTime(detailGroup.lastEnqueueTime)}
+              </Text>
+            </div>
+          </div>
+        )}
+      </Modal>
     </div>
   );
 };

Reply via email to