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 72d870d0 [ISSUE #1618] Serialize NameServer mutations (#1628)
72d870d0 is described below

commit 72d870d0502737957fb7b5e239f747d6366b236e
Author: 0 <[email protected]>
AuthorDate: Tue Aug 11 20:32:30 2026 +0800

    [ISSUE #1618] Serialize NameServer mutations (#1628)
---
 web/src/pages/studio/Ops.tsx                | 37 +++++++++++++++++++++++++----
 web/src/pages/studio/__tests__/Ops.test.tsx | 16 ++++++++++++-
 2 files changed, 48 insertions(+), 5 deletions(-)

diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx
index bd7f3fba..49055660 100644
--- a/web/src/pages/studio/Ops.tsx
+++ b/web/src/pages/studio/Ops.tsx
@@ -53,8 +53,10 @@ const OpsPage: React.FC = () => {
   const [newNamesrvAddr, setNewNamesrvAddr] = useState('');
   const [useVIPChannel, setUseVIPChannel] = useState(false);
   const [useTLS, setUseTLS] = useState(false);
+  const [namesrvUpdating, setNamesrvUpdating] = useState(false);
   const [vipUpdating, setVipUpdating] = useState(false);
   const [tlsUpdating, setTlsUpdating] = useState(false);
+  const namesrvMutationInFlight = useRef(false);
   const vipUpdateInFlight = useRef(false);
   const tlsUpdateInFlight = useRef(false);
   const [configurationAvailable, setConfigurationAvailable] = useState(false);
@@ -93,20 +95,29 @@ const OpsPage: React.FC = () => {
   }, [fetchFailedMessage, message]);
 
   const handleUpdateNameSvrAddr = async () => {
+    if (namesrvMutationInFlight.current) return;
     if (!selectedNamesrv) {
       message.warning(t('ops.selectNamesrv'));
       return;
     }
+    namesrvMutationInFlight.current = true;
+    setNamesrvUpdating(true);
     try {
       await updateNameSvrAddr(selectedNamesrv);
       setCurrentNamesrv(selectedNamesrv);
       message.success(t('common.success'));
     } catch {
       message.error(t('common.failure'));
+    } finally {
+      namesrvMutationInFlight.current = false;
+      setNamesrvUpdating(false);
     }
   };
 
   const handleDeleteNameSvrAddr = async () => {
+    if (namesrvMutationInFlight.current) return;
+    namesrvMutationInFlight.current = true;
+    setNamesrvUpdating(true);
     try {
       await deleteNameSvrAddr(selectedNamesrv);
       setNamesrvAddrList((addresses) => addresses.filter((addr) => addr !== 
selectedNamesrv));
@@ -114,15 +125,21 @@ const OpsPage: React.FC = () => {
       message.success(t('common.success'));
     } catch {
       message.error(t('common.failure'));
+    } finally {
+      namesrvMutationInFlight.current = false;
+      setNamesrvUpdating(false);
     }
   };
 
   const handleAddNameSvrAddr = async () => {
+    if (namesrvMutationInFlight.current) return;
     const addr = newNamesrvAddr.trim();
     if (!addr) {
       message.warning(t('ops.inputNamesrvAddr'));
       return;
     }
+    namesrvMutationInFlight.current = true;
+    setNamesrvUpdating(true);
     try {
       await addNameSvrAddr(addr);
       if (!namesrvAddrList.includes(addr)) {
@@ -132,6 +149,9 @@ const OpsPage: React.FC = () => {
       message.success(t('common.success'));
     } catch {
       message.error(t('common.failure'));
+    } finally {
+      namesrvMutationInFlight.current = false;
+      setNamesrvUpdating(false);
     }
   };
 
@@ -188,7 +208,7 @@ const OpsPage: React.FC = () => {
             style={{ minWidth: 400, maxWidth: 500 }}
             value={selectedNamesrv || undefined}
             onChange={setSelectedNamesrv}
-            disabled={!writeOperationEnabled}
+            disabled={!writeOperationEnabled || namesrvUpdating}
             placeholder={t('ops.selectNamesrv')}
             options={namesrvAddrList.map((addr) => ({ label: addr, value: addr 
}))}
           />
@@ -197,6 +217,8 @@ const OpsPage: React.FC = () => {
               type="primary"
               icon={<FloppyDisk size={16} />}
               onClick={handleUpdateNameSvrAddr}
+              loading={namesrvUpdating}
+              disabled={namesrvUpdating}
             >
               {t('common.update')}
             </Button>
@@ -207,14 +229,14 @@ const OpsPage: React.FC = () => {
               onConfirm={handleDeleteNameSvrAddr}
               okText={t('common.confirm')}
               cancelText={t('common.cancel')}
-              disabled={deleteNameServerDisabled}
+              disabled={deleteNameServerDisabled || namesrvUpdating}
             >
               <Tooltip title={t('common.delete')}>
                 <Button
                   danger
                   aria-label={t('common.delete')}
                   icon={<Trash size={16} />}
-                  disabled={deleteNameServerDisabled}
+                  disabled={deleteNameServerDisabled || namesrvUpdating}
                 />
               </Tooltip>
             </Popconfirm>
@@ -226,8 +248,15 @@ const OpsPage: React.FC = () => {
                 placeholder="NamesrvAddr"
                 value={newNamesrvAddr}
                 onChange={(e) => setNewNamesrvAddr(e.target.value)}
+                disabled={namesrvUpdating}
               />
-              <Button type="primary" icon={<Plus size={16} />} 
onClick={handleAddNameSvrAddr}>
+              <Button
+                type="primary"
+                icon={<Plus size={16} />}
+                onClick={handleAddNameSvrAddr}
+                loading={namesrvUpdating}
+                disabled={namesrvUpdating}
+              >
                 {t('common.add')}
               </Button>
             </Space.Compact>
diff --git a/web/src/pages/studio/__tests__/Ops.test.tsx 
b/web/src/pages/studio/__tests__/Ops.test.tsx
index 31c147da..c7803a48 100644
--- a/web/src/pages/studio/__tests__/Ops.test.tsx
+++ b/web/src/pages/studio/__tests__/Ops.test.tsx
@@ -22,7 +22,7 @@ import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import OpsPage from '../Ops';
-import { deleteNameSvrAddr, queryOpsHomePage, updateIsVIPChannel } from 
'../../../api/ops';
+import { addNameSvrAddr, deleteNameSvrAddr, queryOpsHomePage, 
updateIsVIPChannel } from '../../../api/ops';
 import useAuthStore from '../../../stores/authStore';
 
 vi.mock('../../../api/ops', () => ({
@@ -85,6 +85,20 @@ describe('OpsPage', () => {
     expect(screen.getAllByRole('switch')[1]).not.toBeChecked();
   });
 
+  it('prevents overlapping NameServer mutations', async () => {
+    vi.mocked(addNameSvrAddr).mockImplementation(() => new Promise(() => {}));
+    renderWithProviders(<OpsPage />);
+
+    const input = await screen.findByPlaceholderText('NamesrvAddr');
+    fireEvent.change(input, { target: { value: '127.0.0.3:9876' } });
+    const addButton = screen.getByRole('button', { name: /新增|添加/ });
+    fireEvent.click(addButton);
+    fireEvent.click(addButton);
+
+    await waitFor(() => expect(addNameSvrAddr).toHaveBeenCalledTimes(1));
+    expect(addButton).toBeDisabled();
+  });
+
   it('prevents overlapping VIP channel updates', async () => {
     let resolveUpdate!: () => void;
     vi.mocked(updateIsVIPChannel).mockReturnValue(

Reply via email to