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(