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>

Reply via email to