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 e8fc8b037 fix(web): warn on partially matched DLQ selected resends
instead of success (#4912)
e8fc8b037 is described below
commit e8fc8b037a738905c6675f00d7c5af94f5a08550
Author: Apulupie <[email protected]>
AuthorDate: Mon Oct 19 19:57:35 2026 +0800
fix(web): warn on partially matched DLQ selected resends instead of success
(#4912)
Verified against `rocketmq-studio` at 3bcd14d45 before merging: the defect
is still present on the
current trunk and the change merges cleanly.
Maintainer follow-ups on top of the contributor commit:
- fix(dlq): report unmatched and unscanned queues on the failed selected
resend too
Each follow-up commit message records the review finding it addresses.
Locally verified: targeted suites green on the integration branch; a full
backend and frontend
build plus test pass runs on the trunk once this batch lands.
---
web/src/i18n/translations.ts | 8 ++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 147 ++++++++++++++++++++++
web/src/pages/instance/dlq.tsx | 27 ++++
3 files changed, 182 insertions(+)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index bed2185af..de81f7b05 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -911,6 +911,14 @@ const translations: Record<string, Record<Lang, string>> =
{
zh: '重投失败:成功 {resent},失败 {failed}',
en: 'Resend failed: {resent} succeeded, {failed} failed',
},
+ 'dlq.resendUnmatchedSummary': {
+ zh: '重投不完整:成功 {resent},{unmatched} 条无法定位',
+ en: 'Resend incomplete: {resent} succeeded, {unmatched} could not be
located',
+ },
+ 'dlq.resendScanIncompleteSummary': {
+ zh: '重投不完整:成功 {resent},{queues} 个队列无法扫描',
+ en: 'Resend incomplete: {resent} succeeded, {queues} queues could not be
scanned',
+ },
'dlq.failureDetails': { zh: '失败消息明细', en: 'Failed message details' },
'dlq.failureMessageId': { zh: '消息 ID', en: 'Message ID' },
'dlq.failureTargetTopic': { zh: '目标 Topic', en: 'Target topic' },
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 6b068a5ed..6fa46b1ee 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -1178,4 +1178,151 @@ describe('DLQ page', () => {
expect(screen.queryByText('cg-order')).not.toBeInTheDocument();
expect(screen.getByText('-cg-"payment"')).toBeInTheDocument();
});
+
+ it('warns instead of celebrating when the selected resend could not match
every message', async () => {
+ vi.mocked(messageService.listDLQMessages).mockResolvedValue({
+ items: [
+ {
+ msgId: 'dlq-gone',
+ topic: '%DLQ%cg-order',
+ queueId: 0,
+ offset: 7,
+ storeTime: 1_700_000_000_000,
+ keys: null,
+ body: 'payload',
+ bodyBase64: null,
+ properties: {},
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ // The selection was captured before the messages were purged: the server
reports matched=0,
+ // resent=0, failed=0 with outcome PARTIAL, and the old logic fell through
to the success toast.
+ vi.mocked(messageService.resendDLQSelected).mockResolvedValue({
+ matched: 0,
+ resent: 0,
+ failed: 0,
+ outcome: 'PARTIAL',
+ scanIncomplete: false,
+ failedQueueCount: 0,
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<DLQPage />);
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!orderRow) throw new Error('DLQ group row not found');
+ await user.click(within(orderRow).getByRole('button', { name: /消息明细/ }));
+ const messageRow = (await screen.findByText('dlq-gone')).closest('tr');
+ if (!messageRow) throw new Error('DLQ message row not found');
+ await user.click(within(messageRow).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: /批量重发选中/ }));
+
+ expect(await screen.findByText(/1 条无法定位/)).toBeInTheDocument();
+ expect(screen.queryByText(/重发完成:成功 0 条/)).not.toBeInTheDocument();
+ });
+
+ it('warns when a queue could not be scanned for the selected resend', async
() => {
+ vi.mocked(messageService.listDLQMessages).mockResolvedValue({
+ items: [
+ {
+ msgId: 'dlq-scanned',
+ topic: '%DLQ%cg-order',
+ queueId: 0,
+ offset: 9,
+ storeTime: 1_700_000_000_000,
+ keys: null,
+ body: 'payload',
+ bodyBase64: null,
+ properties: {},
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ // The message matched, but one queue refused the scan: the caller has to
hear that the result
+ // is incomplete, exactly like the time-range resend path reports it.
+ vi.mocked(messageService.resendDLQSelected).mockResolvedValue({
+ matched: 1,
+ resent: 0,
+ failed: 0,
+ outcome: 'PARTIAL',
+ scanIncomplete: true,
+ failedQueueCount: 2,
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<DLQPage />);
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!orderRow) throw new Error('DLQ group row not found');
+ await user.click(within(orderRow).getByRole('button', { name: /消息明细/ }));
+ const messageRow = (await screen.findByText('dlq-scanned')).closest('tr');
+ if (!messageRow) throw new Error('DLQ message row not found');
+ await user.click(within(messageRow).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: /批量重发选中/ }));
+
+ expect(await screen.findByText(/2 个队列无法扫描/)).toBeInTheDocument();
+ expect(screen.queryByText(/重发完成:成功 0 条/)).not.toBeInTheDocument();
+ });
+
+ it('keeps the unmatched warning when the selected resend also reports
failures', async () => {
+ vi.mocked(messageService.listDLQMessages).mockResolvedValue({
+ items: [
+ {
+ msgId: 'dlq-failed',
+ topic: '%DLQ%cg-order',
+ queueId: 0,
+ offset: 11,
+ storeTime: 1_700_000_000_000,
+ keys: null,
+ body: 'payload',
+ bodyBase64: null,
+ properties: {},
+ },
+ {
+ msgId: 'dlq-unmatched',
+ topic: '%DLQ%cg-order',
+ queueId: 1,
+ offset: 12,
+ storeTime: 1_700_000_000_000,
+ keys: null,
+ body: 'payload',
+ bodyBase64: null,
+ properties: {},
+ },
+ ],
+ total: 2,
+ page: 1,
+ size: 20,
+ });
+ // Both rows are selected: one fails outright and one can no longer be
resolved. The failure
+ // summary reports only resent + failed, so the unmatched count still has
to be surfaced —
+ // this is the path the bare `failed > 0` branch used to swallow.
+ vi.mocked(messageService.resendDLQSelected).mockResolvedValue({
+ matched: 1,
+ resent: 0,
+ failed: 1,
+ outcome: 'PARTIAL',
+ scanIncomplete: true,
+ failedQueueCount: 3,
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<DLQPage />);
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!orderRow) throw new Error('DLQ group row not found');
+ await user.click(within(orderRow).getByRole('button', { name: /消息明细/ }));
+ for (const msgId of ['dlq-failed', 'dlq-unmatched']) {
+ const messageRow = (await screen.findByText(msgId)).closest('tr');
+ if (!messageRow) throw new Error(`DLQ message row not found: ${msgId}`);
+ await user.click(within(messageRow).getByRole('checkbox'));
+ }
+ await user.click(screen.getByRole('button', { name: /批量重发选中/ }));
+
+ expect(await screen.findByText(/重投部分完成:成功 0,失败 1/)).toBeInTheDocument();
+ expect(await screen.findByText(/1 条无法定位/)).toBeInTheDocument();
+ expect(screen.queryByText(/重发完成:成功 0 条/)).not.toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 566d2d780..354b54803 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -448,8 +448,35 @@ const DLQPage = () => {
msgIds,
});
if (detailResendRequestIdRef.current !== requestId) return;
+ // The server reports `matched` separately from `resent`: a selected
message that can no
+ // longer be resolved (purged, broker gone) is dropped with failed=0,
and a queue that could
+ // not be scanned is reported separately too. Mirror the time-range
resend handler: never show
+ // the success toast unless every selected message was matched and
resent.
+ const unmatched = msgIds.length - result.matched;
if (result.failed > 0) {
showResendFailures(result);
+ // `showResendFailures` only reports resent + failed, so the
partial-match information
+ // would be dropped on this path. Emit the same supplementary warning
as the branches
+ // below, mirroring the time-range resend handler which also warns on
top of the failure
+ // summary when its scan was incomplete.
+ if (unmatched > 0 || result.scanIncomplete) {
+ message.warning(
+ t(unmatched > 0 ? 'dlq.resendUnmatchedSummary' :
'dlq.resendScanIncompleteSummary', {
+ resent: result.resent,
+ unmatched,
+ queues: result.failedQueueCount ?? 0,
+ }),
+ );
+ }
+ } else if (unmatched > 0) {
+ message.warning(t('dlq.resendUnmatchedSummary', { resent:
result.resent, unmatched }));
+ } else if (result.scanIncomplete) {
+ message.warning(
+ t('dlq.resendScanIncompleteSummary', {
+ resent: result.resent,
+ queues: result.failedQueueCount ?? 0,
+ }),
+ );
} else {
message.success(`重发完成:成功 ${result.resent} 条`);
}