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 3eff1f0ca fix(web): keep an open delivery drawer in step with a
refreshed row (#5665)
3eff1f0ca is described below
commit 3eff1f0cae75cad995dc3c228e4ae71922d652c2
Author: Apulupie <[email protected]>
AuthorDate: Mon Oct 19 19:59:37 2026 +0800
fix(web): keep an open delivery drawer in step with a refreshed row (#5665)
Verified against `rocketmq-studio` at e8fc8b037 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(web): patch the page row a retried delivery dialog reads
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.
---
.../__tests__/NotificationDeliveriesPage.test.tsx | 107 ++++++++++++++++++++-
web/src/pages/ops/notificationDeliveries.tsx | 58 +++++++----
2 files changed, 144 insertions(+), 21 deletions(-)
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index 8049d8ec9..bc98d0407 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -5,12 +5,16 @@
* The ASF licenses this file to You under the Apache License, Version 2.0.
*/
import { App } from 'antd';
-import { act, render, screen, waitFor } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { LangProvider } from '../../../i18n/LangContext';
import { listInstances } from '../../../services/instanceService';
-import { listAlertDeliveriesPage, retryAlertDelivery } from
'../../../services/opsService';
+import {
+ listAlertDeliveriesPage,
+ retryAlertDeliveries,
+ retryAlertDelivery,
+} from '../../../services/opsService';
import NotificationDeliveriesPage from '../notificationDeliveries';
vi.mock('../../../services/instanceService', () => ({
@@ -172,6 +176,105 @@ describe('NotificationDeliveriesPage', () => {
);
});
+ it('keeps an open detail dialog in step with a bulk retry that queued its
delivery', async () => {
+ const failed = {
+ id: 7,
+ alertId: 3,
+ alertTitle: 'Broker disk usage',
+ channel: 'dingtalk',
+ status: 'FAILED' as const,
+ attemptCount: 5,
+ createdAt: '2026-08-23T10:00:00',
+ lastError: 'Webhook rejected the request',
+ };
+ vi.mocked(listAlertDeliveriesPage)
+ .mockResolvedValueOnce({ items: [failed], total: 1, page: 1, size: 20 })
+ .mockResolvedValue({
+ items: [{ ...failed, status: 'PENDING', attemptCount: 0, lastError:
undefined }],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ vi.mocked(retryAlertDeliveries).mockResolvedValue({ succeededIds: [7],
failures: {} });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <App>
+ <LangProvider>
+ <NotificationDeliveriesPage />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('Broker disk usage');
+ await user.click(screen.getByRole('button', { name: '查看投递详情' }));
+ const dialog = await screen.findByRole('dialog', { name: '投递详情' });
+ expect(within(dialog).getByText('FAILED')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /重试当前页失败记录/ }));
+
+ // The queued retry is the page's new truth; the dialog must not keep
offering a retry the
+ // backend would reject as "not failed any more".
+ await waitFor(() =>
expect(within(dialog).getByText('PENDING')).toBeInTheDocument());
+ expect(within(dialog).queryByText('FAILED')).not.toBeInTheDocument();
+ expect(within(dialog).queryByRole('button', { name: '重新投递'
})).not.toBeInTheDocument();
+ });
+
+ it('patches the row behind an open detail dialog when a single retry is
queued', async () => {
+ const reload = deferred<Awaited<ReturnType<typeof
listAlertDeliveriesPage>>>();
+ vi.mocked(listAlertDeliveriesPage)
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 7,
+ alertId: 3,
+ alertTitle: 'Broker disk usage',
+ channel: 'dingtalk',
+ status: 'FAILED',
+ attemptCount: 5,
+ createdAt: '2026-08-23T10:00:00',
+ lastError: 'Webhook rejected the request',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ })
+ // Held so the assertions below run in the window where the dialog can
only see the row the
+ // previous load left on the page.
+ .mockImplementationOnce(() => reload.promise);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <App>
+ <LangProvider>
+ <NotificationDeliveriesPage />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('Broker disk usage');
+ await user.click(screen.getByRole('button', { name: '查看投递详情' }));
+ const dialog = await screen.findByRole('dialog', { name: '投递详情' });
+ expect(within(dialog).getByText('FAILED')).toBeInTheDocument();
+
+ await user.click(within(dialog).getByRole('button', { name: '重新投递' }));
+
+ // The dialog reads the page's row before the snapshot it opened with, so
a single-row retry has
+ // to patch that row: otherwise the stale FAILED copy wins for as long as
the reload is in
+ // flight and the dialog keeps offering a retry the backend refuses.
+ await waitFor(() =>
expect(within(dialog).getByText('PENDING')).toBeInTheDocument());
+ expect(within(dialog).queryByText('FAILED')).not.toBeInTheDocument();
+ expect(within(dialog).queryByRole('button', { name: '重新投递'
})).not.toBeInTheDocument();
+
+ // …and it stays queued once the held reload lands, whether or not the
server still returns the
+ // row on this page (a FAILED filter drops it as soon as the retry is
queued).
+ act(() => {
+ reload.resolve({ items: [], total: 0, page: 1, size: 20 });
+ });
+ await waitFor(() =>
expect(within(dialog).getByText('PENDING')).toBeInTheDocument());
+ expect(within(dialog).queryByText('FAILED')).not.toBeInTheDocument();
+ expect(within(dialog).queryByRole('button', { name: '重新投递'
})).not.toBeInTheDocument();
+ });
+
it('surfaces a failed instance-list load with a retry instead of an empty
filter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(listInstances)
diff --git a/web/src/pages/ops/notificationDeliveries.tsx
b/web/src/pages/ops/notificationDeliveries.tsx
index c8af0609e..6a3340d9b 100644
--- a/web/src/pages/ops/notificationDeliveries.tsx
+++ b/web/src/pages/ops/notificationDeliveries.tsx
@@ -83,6 +83,18 @@ const NotificationDeliveriesPage = () => {
try {
await retryAlertDelivery(record.id);
message.success(t('deliveries.retryQueued'));
+ // The dialog reads `items` first and the snapshot only as a fallback,
so the optimistic patch
+ // has to cover both: without the row patch, the stale FAILED row still
on the page wins until
+ // the reload lands; without the snapshot patch, a row the reload drops
off the page (a FAILED
+ // filter stops matching it once the retry is queued) falls back to
FAILED and offers a retry
+ // the backend refuses.
+ setItems((rows) =>
+ rows.map((row) =>
+ row.id === record.id
+ ? { ...row, status: 'PENDING', attemptCount: 0, lastError: null }
+ : row,
+ ),
+ );
setSelectedDelivery((current) =>
current?.id === record.id
? { ...current, status: 'PENDING', attemptCount: 0, lastError: null }
@@ -177,6 +189,14 @@ const NotificationDeliveriesPage = () => {
};
}, [channel, status, instanceId, search, timeRange, page, pageSize,
refreshNonce, t]);
+ // The dialog shows the freshest row it can see. A bulk retry — or another
operator — reloads the
+ // page, and a snapshot taken when the dialog opened would keep rendering
FAILED and offer a retry
+ // the backend refuses ("Only failed notification deliveries can be
retried"). The snapshot stays
+ // as the fallback for a row the current page no longer carries.
+ const currentDelivery = selectedDelivery
+ ? (items.find((item) => item.id === selectedDelivery.id) ??
selectedDelivery)
+ : undefined;
+
const resetPage = (change: () => void) => {
setLoading(true);
setLoadFailed(false);
@@ -400,7 +420,7 @@ const NotificationDeliveriesPage = () => {
<Modal
title={t('deliveries.details')}
width="min(640px, calc(100vw - 32px))"
- open={selectedDelivery !== undefined}
+ open={currentDelivery !== undefined}
onCancel={() => setSelectedDelivery(undefined)}
footer={null}
destroyOnHidden
@@ -408,50 +428,50 @@ const NotificationDeliveriesPage = () => {
body: { maxHeight: 'calc(100vh - 240px)', overflowY: 'auto',
overflowX: 'hidden' },
}}
>
- {selectedDelivery && (
+ {currentDelivery && (
<Flex vertical gap={20}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label={t('deliveries.event')}>
- {selectedDelivery.alertTitle} · #{selectedDelivery.alertId}
·{' '}
- {selectedDelivery.transition ?? '-'}
+ {currentDelivery.alertTitle} · #{currentDelivery.alertId} ·{'
'}
+ {currentDelivery.transition ?? '-'}
</Descriptions.Item>
<Descriptions.Item label={t('deliveries.instance')}>
- {selectedDelivery.instanceId ?? '-'}
+ {currentDelivery.instanceId ?? '-'}
</Descriptions.Item>
<Descriptions.Item label={t('deliveries.channel')}>
- {selectedDelivery.channel}
+ {currentDelivery.channel}
</Descriptions.Item>
<Descriptions.Item label={t('common.status')}>
- <Tag
color={statusColors[selectedDelivery.status]}>{selectedDelivery.status}</Tag>
+ <Tag
color={statusColors[currentDelivery.status]}>{currentDelivery.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('deliveries.attempts')}>
- {selectedDelivery.attemptCount}
+ {currentDelivery.attemptCount}
</Descriptions.Item>
<Descriptions.Item label={t('deliveries.createdAt')}>
- {formatUtcDateTime(selectedDelivery.createdAt)}
+ {formatUtcDateTime(currentDelivery.createdAt)}
</Descriptions.Item>
<Descriptions.Item label={t('deliveries.deliveredAt')}>
- {formatUtcDateTime(selectedDelivery.deliveredAt)}
+ {formatUtcDateTime(currentDelivery.deliveredAt)}
</Descriptions.Item>
- {selectedDelivery.nextAttemptAt && (
+ {currentDelivery.nextAttemptAt && (
<Descriptions.Item label={t('deliveries.retryAt')}>
- {formatUtcDateTime(selectedDelivery.nextAttemptAt)}
+ {formatUtcDateTime(currentDelivery.nextAttemptAt)}
</Descriptions.Item>
)}
- {selectedDelivery.lastError && (
+ {currentDelivery.lastError && (
<Descriptions.Item label={t('deliveries.result')}>
<Typography.Text type="danger" style={{ overflowWrap:
'anywhere' }}>
- {localizeDeliveryError(selectedDelivery.lastError, t)}
+ {localizeDeliveryError(currentDelivery.lastError, t)}
</Typography.Text>
</Descriptions.Item>
)}
</Descriptions>
- {selectedDelivery.status === 'FAILED' && (
+ {currentDelivery.status === 'FAILED' && (
<Button
icon={<ArrowClockwise size={18} />}
- loading={retryingIds.has(selectedDelivery.id)}
+ loading={retryingIds.has(currentDelivery.id)}
disabled={loading || loadFailed}
- onClick={() => void retryDelivery(selectedDelivery)}
+ onClick={() => void retryDelivery(currentDelivery)}
>
{t('deliveries.retry')}
</Button>
@@ -459,7 +479,7 @@ const NotificationDeliveriesPage = () => {
<div>
<Typography.Text
strong>{t('deliveries.messageContent')}</Typography.Text>
<Typography.Paragraph
- copyable={{ text: selectedDelivery.messageContent ?? '' }}
+ copyable={{ text: currentDelivery.messageContent ?? '' }}
style={{
marginTop: 8,
marginBottom: 0,
@@ -467,7 +487,7 @@ const NotificationDeliveriesPage = () => {
overflowWrap: 'anywhere',
}}
>
- {selectedDelivery.messageContent || '-'}
+ {currentDelivery.messageContent || '-'}
</Typography.Paragraph>
</div>
</Flex>