Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<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) {
}
}
Original file line number Diff line number Diff line change
@@ -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<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");
}
}
124 changes: 124 additions & 0 deletions web/src/pages/instance/__tests__/DLQPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
93 changes: 91 additions & 2 deletions web/src/pages/instance/dlq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ─── */
Expand Down Expand Up @@ -196,7 +222,7 @@ const DLQPage = () => {
size="small"
icon={<Eye size={14} />}
style={{ borderColor: '#1677ff', color: '#1677ff' }}
onClick={() => message.info(`查看 ${record.groupName} 详情(模拟)`)}
onClick={() => setDetailGroup(record)}
>
查看详情
</Button>
Expand Down Expand Up @@ -345,6 +371,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>
);
};
Expand Down
Loading