diff --git a/server/src/main/java/com/rocketmq/studio/instance/dlq/DLQProviderStub.java b/server/src/main/java/com/rocketmq/studio/instance/dlq/DLQProviderStub.java index 76b48ba8..4404eb1e 100644 --- a/server/src/main/java/com/rocketmq/studio/instance/dlq/DLQProviderStub.java +++ b/server/src/main/java/com/rocketmq/studio/instance/dlq/DLQProviderStub.java @@ -18,22 +18,68 @@ 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 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 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/com/rocketmq/studio/instance/dlq/DLQProviderStubTest.java b/server/src/test/java/com/rocketmq/studio/instance/dlq/DLQProviderStubTest.java new file mode 100644 index 00000000..68e047a4 --- /dev/null +++ b/server/src/test/java/com/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 com.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 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 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 firstRead = provider.listDLQGroups("rmq-cn-v5-prod-01"); + firstRead.get(0).setGroupName("mutated"); + + List 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( + + {ui} + , + ); + +describe('DLQ page', () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + let clickSpy: ReturnType; + + 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(); + + 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(); + + 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(); + + 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..afe486a5 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(null); useEffect(() => { let cancelled = false; @@ -129,7 +130,32 @@ 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 +222,7 @@ const DLQPage = () => { size="small" icon={} style={{ borderColor: '#1677ff', color: '#1677ff' }} - onClick={() => message.info(`查看 ${record.groupName} 详情(模拟)`)} + onClick={() => setDetailGroup(record)} > 查看详情 @@ -345,6 +371,69 @@ const DLQPage = () => { )} + + setDetailGroup(null)} + footer={} + width={560} + destroyOnClose + > + {detailGroup && ( +
+
+ + Group 名称 + + + {detailGroup.groupName} + +
+
+ + DLQ Topic + + + {detailGroup.dlqTopic} + +
+ +
+ + 死信数量 + + 0 ? '#fa8c16' : undefined }} + > + {detailGroup.messageCount.toLocaleString()} + +
+
+ + 重试次数 + + {detailGroup.retryCount.toLocaleString()} +
+
+ + 状态 + + {detailGroup.status} +
+
+
+ + 最近入队时间 + + + {formatDateTime(detailGroup.lastEnqueueTime)} + +
+
+ )} +
); };