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 579918565 fix(web): three console pages kept acting on stale data 
after a failed load (#5032)
579918565 is described below

commit 5799185652729ae1cb49e66b010e5b1ec1c4d5d9
Author: zmuxuny <[email protected]>
AuthorDate: Fri Oct 9 23:37:18 2026 -0700

    fix(web): three console pages kept acting on stale data after a failed load 
(#5032)
    
    Three frontend defects where a failed or in-flight load left the page 
acting on data it no longer had.
    
    - The broker-config dialog stayed open with its old preview after a PARTIAL 
apply, and the preview
      button was not disabled while a write was in flight; both now follow the 
`nsSubmittingRef`
      convention already used in the same file. (#5032)
    - The cloud-credential tab kept the previous rows after a load failure, so 
their action buttons could
      still rotate or delete a credential that was no longer listed. (#5253)
    - The proxy page wrote a stale node list and address back after a failed 
health probe, because the
      ownership check was bypassed by the `catch`. (#5256)
    
    `ClusterPage` + `CloudCredentialTab` + `Proxy` suites: 67 tests green; 
prettier clean.
    
    Folded in #5253 and #5256 (same author).
---
 .../pages/cluster/__tests__/ClusterPage.test.tsx   | 38 ++++++++++
 web/src/pages/cluster/index.tsx                    |  5 ++
 web/src/pages/settings/CloudCredentialTab.tsx      | 43 +++++++++--
 .../settings/__tests__/CloudCredentialTab.test.tsx | 85 +++++++++++++++++++++-
 web/src/pages/studio/Proxy.tsx                     |  3 +-
 web/src/pages/studio/__tests__/Proxy.test.tsx      | 36 ++++++++-
 6 files changed, 199 insertions(+), 11 deletions(-)

diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 1eb02f723..3d57348bb 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -598,6 +598,44 @@ describe('Cluster page', () => {
     
expect(within(dialog).queryByText('defaultTopicQueueNums=16')).not.toBeInTheDocument();
   });
 
+  it('discards broker config previews after a partial update changes the 
cluster', async () => {
+    const latePreview = deferred<ClusterConfigPreviewResult>();
+    clusterServiceMocks.updateClusterConfig.mockResolvedValue({
+      cluster: buildCluster(),
+      status: 'PARTIAL',
+      successfulBrokers: ['10.101.2.11:10911'],
+      failedBrokers: [{ address: '10.101.2.12:10911', message: 'timeout' }],
+    });
+    renderWithProviders(<ClusterPage />);
+
+    const brokerRow = await screen.findByRole('row', { name: 
/10\.101\.2\.11:10911/ });
+    fireEvent.click(within(brokerRow).getByRole('button', { name: /^配\s*置$/ 
}));
+    const dialog = await screen.findByRole('dialog', { name: /配置 - 
rocketmq-prod/ });
+    fireEvent.click(within(dialog).getByRole('button', { name: /预\s*览/ }));
+    expect(await 
within(dialog).findByText('defaultTopicQueueNums=8')).toBeInTheDocument();
+
+    
clusterServiceMocks.previewClusterConfig.mockReturnValueOnce(latePreview.promise);
+    fireEvent.click(within(dialog).getByRole('button', { name: /预\s*览/ }));
+    await waitFor(() => 
expect(clusterServiceMocks.previewClusterConfig).toHaveBeenCalledTimes(2));
+    fireEvent.click(within(dialog).getByRole('button', { name: /^ok$/i }));
+    expect(await screen.findByText(/部分 Broker 配置已更新/)).toBeInTheDocument();
+    
expect(within(dialog).queryByText('defaultTopicQueueNums=8')).not.toBeInTheDocument();
+
+    await act(async () => {
+      latePreview.resolve({
+        cluster: buildCluster(),
+        currentConfig: buildCluster().config!,
+        proposedConfig: { ...buildCluster().config!, writeQueueNums: 24 },
+        targetBrokers: [{ name: 'rocketmq-prod-0', address: 
'10.101.2.11:10911' }],
+        brokerProperties: { defaultTopicQueueNums: '24' },
+        changes: [],
+        changed: true,
+      });
+      await latePreview.promise;
+    });
+    
expect(within(dialog).queryByText('defaultTopicQueueNums=24')).not.toBeInTheDocument();
+  });
+
   it('keeps cluster tabs usable when address fields are missing', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     const submitSearch = async (placeholder: string, value: string) => {
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 86fd9b36d..cf33221c9 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -763,6 +763,11 @@ const ClusterPage = () => {
   };
 
   const handleConfigSubmit = async () => {
+    // A write can change only some Brokers. Any preview from before the write,
+    // including one still in flight, no longer describes the cluster 
afterward.
+    configPreviewRequest.invalidate();
+    setConfigPreview(null);
+    setConfigPreviewLoading(false);
     let values: ClusterConfigFormValues;
     try {
       values = await configForm.validateFields();
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx 
b/web/src/pages/settings/CloudCredentialTab.tsx
index f4482bde5..d8627f764 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -17,6 +17,7 @@
 
 import { useCallback, useEffect, useRef, useState } from 'react';
 import {
+  Alert,
   Button,
   Input,
   Descriptions,
@@ -71,6 +72,8 @@ export const CloudCredentialTab = () => {
   const [search, setSearch] = useState('');
   const [debouncedSearch, setDebouncedSearch] = useState('');
   const [loading, setLoading] = useState(true);
+  const [loadFailed, setLoadFailed] = useState(false);
+  const [loadedQueryKey, setLoadedQueryKey] = useState<string | null>(null);
   const [modalOpen, setModalOpen] = useState(false);
   const [editingCredential, setEditingCredential] = useState<CloudCredential | 
null>(null);
   const [form] = Form.useForm<CredentialFormValues>();
@@ -78,6 +81,7 @@ export const CloudCredentialTab = () => {
   const [exporting, setExporting] = useState(false);
   const requestSeqRef = useRef(0);
   const submitInFlightRef = useRef(false);
+  const queryKey = JSON.stringify([vendorFilter, debouncedSearch, page, 
pageSize]);
 
   useEffect(() => {
     const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 
300);
@@ -89,6 +93,7 @@ export const CloudCredentialTab = () => {
     Promise.resolve().then(() => {
       if (requestId === requestSeqRef.current) {
         setLoading(true);
+        setLoadFailed(false);
       }
     });
     return (async () => {
@@ -104,9 +109,13 @@ export const CloudCredentialTab = () => {
         }
         setCredentials(result.items);
         setTotal(result.total);
+        setLoadedQueryKey(queryKey);
       } catch {
         if (requestId === requestSeqRef.current) {
-          message.error(t('settings.credentialLoadFailed'));
+          setCredentials([]);
+          setTotal(0);
+          setLoadedQueryKey(null);
+          setLoadFailed(true);
         }
       } finally {
         if (requestId === requestSeqRef.current) {
@@ -114,7 +123,7 @@ export const CloudCredentialTab = () => {
         }
       }
     })();
-  }, [debouncedSearch, page, pageSize, t, vendorFilter]);
+  }, [debouncedSearch, page, pageSize, queryKey, vendorFilter]);
 
   useEffect(() => {
     void loadCredentials();
@@ -169,6 +178,8 @@ export const CloudCredentialTab = () => {
   };
 
   const openEditModal = (credential: CloudCredential) => {
+    if (loading || loadFailed || search.trim() !== debouncedSearch || 
loadedQueryKey !== queryKey)
+      return;
     setEditingCredential(credential);
     form.setFieldsValue({
       name: credential.name,
@@ -218,6 +229,8 @@ export const CloudCredentialTab = () => {
   };
 
   const handleDelete = async (credential: CloudCredential) => {
+    if (loading || loadFailed || search.trim() !== debouncedSearch || 
loadedQueryKey !== queryKey)
+      return;
     try {
       await deleteCloudCredential(credential.id);
       const remainingOnPage = credentials.length - 1;
@@ -232,6 +245,9 @@ export const CloudCredentialTab = () => {
     }
   };
 
+  const filtersPending = search.trim() !== debouncedSearch;
+  const queryMismatch = loadedQueryKey !== queryKey;
+  const staleActionsDisabled = loading || loadFailed || filtersPending || 
queryMismatch;
   const columns: ColumnsType<CloudCredential> = [
     { title: t('common.name'), dataIndex: 'name', key: 'name' },
     {
@@ -260,6 +276,7 @@ export const CloudCredentialTab = () => {
             type="link"
             size="small"
             icon={<EditOutlined />}
+            disabled={staleActionsDisabled}
             onClick={() => openEditModal(record)}
           >
             {t('common.edit')}
@@ -270,7 +287,13 @@ export const CloudCredentialTab = () => {
             okText={t('settings.confirmAction')}
             cancelText={t('common.cancel')}
           >
-            <Button type="link" size="small" danger icon={<DeleteOutlined />}>
+            <Button
+              type="link"
+              size="small"
+              danger
+              icon={<DeleteOutlined />}
+              disabled={staleActionsDisabled}
+            >
               {t('common.delete')}
             </Button>
           </Popconfirm>
@@ -315,18 +338,28 @@ export const CloudCredentialTab = () => {
             type="primary"
             icon={<PlusOutlined />}
             onClick={openCreateModal}
-            disabled={loading}
+            disabled={loading || filtersPending || (queryMismatch && 
!loadFailed)}
           >
             {t('settings.addCredential')}
           </Button>
         </Flex>
       </Flex>
 
+      {loadFailed && (
+        <Alert
+          type="error"
+          showIcon
+          message={t('settings.credentialLoadFailed')}
+          action={<Button onClick={() => void 
loadCredentials()}>{t('common.retry')}</Button>}
+          style={{ marginBottom: 16 }}
+        />
+      )}
+
       <Table<CloudCredential>
         columns={columns}
         dataSource={credentials}
         rowKey="id"
-        loading={loading}
+        loading={loading || filtersPending || (queryMismatch && !loadFailed)}
         pagination={{
           current: page,
           pageSize,
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx 
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
index ae4e045be..6c847a431 100644
--- a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -16,7 +16,7 @@
  */
 
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { downloadBlob } from '../../../utils/download';
@@ -62,10 +62,12 @@ const credentials: CloudCredentialPage = {
 
 const deferred = <T,>() => {
   let resolve!: (value: T) => void;
-  const promise = new Promise<T>((promiseResolve) => {
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((promiseResolve, promiseReject) => {
     resolve = promiseResolve;
+    reject = promiseReject;
   });
-  return { promise, resolve };
+  return { promise, resolve, reject };
 };
 
 const renderTab = () =>
@@ -171,6 +173,83 @@ describe('CloudCredentialTab', () => {
     expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
   });
 
+  it('hides stale credentials after a filtered reload fails and retries the 
current filter', async () => {
+    const filteredLoad = deferred<CloudCredentialPage>();
+    const tencentPage: CloudCredentialPage = {
+      items: [
+        {
+          id: 9,
+          name: 'tencent-prod',
+          vendor: 'TENCENT',
+          accessKey: 'AKID****9999',
+          gmtCreate: '2026-08-18T12:00:00',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    };
+    vi.mocked(listCloudCredentials)
+      .mockResolvedValueOnce(credentials)
+      .mockReturnValueOnce(filteredLoad.promise)
+      .mockResolvedValueOnce(tencentPage);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+
+    await screen.findByText('aliyun-test');
+    await user.click(screen.getAllByRole('combobox')[0]);
+    await user.click(
+      await screen.findByText('腾讯云', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await waitFor(() =>
+      expect(listCloudCredentials).toHaveBeenLastCalledWith('TENCENT', '', 1, 
20),
+    );
+    expect(screen.getByRole('button', { name: /编辑/ })).toBeDisabled();
+    expect(screen.getByRole('button', { name: /删除/ })).toBeDisabled();
+
+    await act(async () => filteredLoad.reject(new Error('credential service 
unavailable')));
+    const error = await screen.findByText('云凭据加载失败,请稍后重试');
+    expect(error.closest('.ant-alert')).not.toBeNull();
+    expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /删除/ 
})).not.toBeInTheDocument();
+    
expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: /^重\s*试$/ }));
+    expect(await screen.findByText('tencent-prod')).toBeInTheDocument();
+    expect(listCloudCredentials).toHaveBeenLastCalledWith('TENCENT', '', 1, 
20);
+    expect(screen.queryByText('云凭据加载失败,请稍后重试')).not.toBeInTheDocument();
+  });
+
+  it('keeps loaded credentials usable when search whitespace does not change 
the query', async () => {
+    renderTab();
+    await screen.findByText('aliyun-test');
+    await waitFor(() => expect(screen.getByRole('button', { name: /编辑/ 
})).toBeEnabled());
+
+    fireEvent.change(screen.getByPlaceholderText('搜索凭据名称'), { target: { value: 
' ' } });
+
+    expect(screen.getByRole('button', { name: /编辑/ })).toBeEnabled();
+    expect(screen.getByRole('button', { name: /删除/ })).toBeEnabled();
+  });
+
+  it('reports an initial credential load failure instead of an empty 
inventory', async () => {
+    vi.mocked(listCloudCredentials)
+      .mockRejectedValueOnce(new Error('credential service unavailable'))
+      .mockResolvedValueOnce({ items: [], total: 0, page: 1, size: 20 });
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+
+    const error = await screen.findByText('云凭据加载失败,请稍后重试');
+    expect(error.closest('.ant-alert')).not.toBeNull();
+    expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
+    
expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: /^重\s*试$/ }));
+    await waitFor(() => expect(listCloudCredentials).toHaveBeenCalledTimes(2));
+    await waitFor(() =>
+      expect(screen.queryByText('云凭据加载失败,请稍后重试')).not.toBeInTheDocument(),
+    );
+  });
+
   it('creates a credential from the modal form', async () => {
     vi.mocked(createCloudCredential).mockResolvedValue({
       id: 2,
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 29a6761aa..3a0cc7593 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -115,7 +115,6 @@ const ProxyPage: React.FC = () => {
       // Overlay the live TCP health view (UP/PARTIAL/DOWN) when the backend 
exposes it.
       try {
         const topology = await getProxyTopology();
-        if (requestId !== loadRequestId.current) return false;
         const statusByAddr = new Map(topology.map((node) => [node.proxyAddr, 
node.status]));
         nodes = baseNodes.map((node) => {
           const probeStatus = statusByAddr.get(node.address);
@@ -132,6 +131,8 @@ const ProxyPage: React.FC = () => {
       } catch {
         // Health probing is best-effort; keep the unknown status when it is 
unavailable.
       }
+      // The probe can fail after a newer list (including a mutation result) 
has committed.
+      if (requestId !== loadRequestId.current) return false;
       setProxyNodes(nodes);
 
       setClusterStats({
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 188d58ea8..081840d35 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -70,10 +70,12 @@ function renderPage() {
 
 const createDeferred = <T,>() => {
   let resolve!: (value: T) => void;
-  const promise = new Promise<T>((resolvePromise) => {
+  let reject!: (reason: Error) => void;
+  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
     resolve = resolvePromise;
+    reject = rejectPromise;
   });
-  return { promise, resolve };
+  return { promise, resolve, reject };
 };
 
 describe('ProxyPage', () => {
@@ -341,4 +343,34 @@ describe('ProxyPage', () => {
     expect(screen.getByText('127.0.0.2:8081')).toBeInTheDocument();
     expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
   });
+
+  it('keeps the latest Proxy list when an older health probe fails last', 
async () => {
+    const olderProbe = createDeferred<Awaited<ReturnType<typeof 
getProxyTopology>>>();
+    const latestHome = {
+      proxyAddrList: ['127.0.0.2:8081'],
+      currentProxyAddr: '127.0.0.2:8081',
+    };
+    vi.mocked(queryProxyHomePage)
+      .mockResolvedValueOnce(proxyHome)
+      .mockResolvedValueOnce(proxyHome)
+      .mockResolvedValueOnce(latestHome);
+    vi.mocked(getProxyTopology)
+      .mockResolvedValueOnce([])
+      .mockReturnValueOnce(olderProbe.promise)
+      .mockResolvedValueOnce([]);
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText('127.0.0.1:8081');
+
+    const refresh = screen.getByRole('button', { name: '刷新' });
+    await user.click(refresh);
+    await waitFor(() => expect(getProxyTopology).toHaveBeenCalledTimes(2));
+    await user.click(refresh);
+    expect(await screen.findByText('127.0.0.2:8081')).toBeInTheDocument();
+
+    await act(async () => olderProbe.reject(new Error('health probe 
unavailable')));
+    expect(screen.getByText('127.0.0.2:8081')).toBeInTheDocument();
+    expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
+    expect(localStorage.getItem('proxyAddr')).toBe('127.0.0.2:8081');
+  });
 });

Reply via email to