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 82c09668 feat: support deleting inactive NameServer addresses (#725)
82c09668 is described below

commit 82c0966828092490cc4c05c64c50a2ee51abdd4a
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:14:30 2026 +0800

    feat: support deleting inactive NameServer addresses (#725)
---
 .../apache/rocketmq/studio/ops/OpsController.java  |  6 +++
 .../org/apache/rocketmq/studio/ops/OpsService.java | 15 +++++++
 .../rocketmq/studio/ops/OpsControllerTest.java     | 22 +++++++++++
 .../apache/rocketmq/studio/ops/OpsServiceTest.java | 34 ++++++++++++++++
 web/src/api/ops.test.ts                            | 11 ++++++
 web/src/api/ops.ts                                 |  4 ++
 web/src/pages/studio/Ops.tsx                       | 39 +++++++++++++++++-
 web/src/pages/studio/__tests__/Ops.test.tsx        | 46 +++++++++++++++++++++-
 8 files changed, 173 insertions(+), 4 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java
index f65901f4..c9b78c4b 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java
@@ -50,6 +50,12 @@ public class OpsController {
         return Result.ok();
     }
 
+    @PostMapping("/deleteNameSvrAddr")
+    public Result<Void> deleteNameSvrAddr(@Valid @RequestBody OpsNameServerDTO 
request) {
+        opsService.deleteNameServer(request.getNamesrvAddr());
+        return Result.ok();
+    }
+
     @PostMapping("/updateIsVIPChannel")
     public Result<Void> updateIsVIPChannel(@Valid @RequestBody 
OpsVipChannelDTO request) {
         opsService.updateVipChannel(request.getUseVIPChannel());
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsService.java
index 5812af14..57ddd095 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsService.java
@@ -57,6 +57,21 @@ public class OpsService {
         log.info("Added NameServer address {}", normalized);
     }
 
+    public synchronized void deleteNameServer(String namesrvAddr) {
+        String normalized = normalizeNameServer(namesrvAddr);
+        if (!namesrvAddrs.contains(normalized)) {
+            throw new BusinessException(404, "NameServer address not found: " 
+ normalized);
+        }
+        if (namesrvAddrs.size() == 1) {
+            throw new BusinessException(409, "Cannot delete the last 
NameServer address");
+        }
+        if (normalized.equals(currentNamesrv)) {
+            throw new BusinessException(409, "Cannot delete the current 
NameServer address");
+        }
+        namesrvAddrs.remove(normalized);
+        log.info("Deleted NameServer address {}", normalized);
+    }
+
     public synchronized void updateVipChannel(boolean enabled) {
         useVIPChannel = enabled;
         log.info("Updated VIP channel setting to {}", enabled);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java
index d0c4c304..b8e2ee96 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java
@@ -115,6 +115,28 @@ class OpsControllerTest {
         verifyNoInteractions(opsService);
     }
 
+    @Test
+    void deleteNameSvrAddrShouldDelegateToService() throws Exception {
+        mockMvc.perform(post("/api/ops/deleteNameSvrAddr")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("namesrvAddr", 
"10.0.0.2:9876"))))
+                .andExpect(status().isOk());
+
+        verify(opsService).deleteNameServer(eq("10.0.0.2:9876"));
+    }
+
+    @Test
+    void deleteNameSvrAddrShouldRejectMissingAddress() throws Exception {
+        mockMvc.perform(post("/api/ops/deleteNameSvrAddr")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("namesrvAddr is 
required"));
+
+        verifyNoInteractions(opsService);
+    }
+
     @Test
     void updateVipChannelShouldDelegateToService() throws Exception {
         mockMvc.perform(post("/api/ops/updateIsVIPChannel")
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/OpsServiceTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/ops/OpsServiceTest.java
index 872c3be6..15e24667 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/OpsServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/OpsServiceTest.java
@@ -49,6 +49,37 @@ class OpsServiceTest {
         assertThat(home.getCurrentNamesrv()).isEqualTo("10.0.0.2:9876");
     }
 
+    @Test
+    void deleteNameServerShouldRemoveNonCurrentAddress() {
+        opsService.addNameServer("10.0.0.1:9876");
+
+        opsService.deleteNameServer(" 10.0.0.1:9876 ");
+
+        OpsHomeVO home = opsService.getHomePage();
+        
assertThat(home.getNamesvrAddrList()).containsExactly("127.0.0.1:9876");
+        assertThat(home.getCurrentNamesrv()).isEqualTo("127.0.0.1:9876");
+    }
+
+    @Test
+    void deleteNameServerShouldRejectUnknownCurrentAndLastAddress() {
+        assertThatThrownBy(() -> opsService.deleteNameServer("10.0.0.1:9876"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("NameServer address not found: 10.0.0.1:9876")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
+
+        assertThatThrownBy(() -> opsService.deleteNameServer("127.0.0.1:9876"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Cannot delete the last NameServer address")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(409));
+
+        opsService.addNameServer("10.0.0.1:9876");
+        opsService.updateNameServer("10.0.0.1:9876");
+        assertThatThrownBy(() -> opsService.deleteNameServer("10.0.0.1:9876"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Cannot delete the current NameServer address")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(409));
+    }
+
     @Test
     void togglesShouldUpdateHomePageSettings() {
         opsService.updateVipChannel(false);
@@ -68,5 +99,8 @@ class OpsServiceTest {
         assertThatThrownBy(() -> opsService.updateNameServer(null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("namesrvAddr is required");
+        assertThatThrownBy(() -> opsService.deleteNameServer(" "))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("namesrvAddr is required");
     }
 }
diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts
index c10952c1..b9e39d38 100644
--- a/web/src/api/ops.test.ts
+++ b/web/src/api/ops.test.ts
@@ -22,6 +22,7 @@ import {
   queryOpsHomePage,
   updateNameSvrAddr,
   addNameSvrAddr,
+  deleteNameSvrAddr,
   updateIsVIPChannel,
   updateUseTLS,
   listAlertRules,
@@ -84,6 +85,16 @@ describe('Ops API - NameServer operations', () => {
     await addNameSvrAddr('10.0.0.2:9876');
   });
 
+  it('deletes a NameServer address', async () => {
+    mock.onPost('/ops/deleteNameSvrAddr').reply((config) => {
+      const body = JSON.parse(config.data);
+      expect(body.namesrvAddr).toBe('10.0.0.2:9876');
+      return [200, { code: 200 }];
+    });
+
+    await deleteNameSvrAddr('10.0.0.2:9876');
+  });
+
   it('updates VIP channel setting', async () => {
     mock.onPost('/ops/updateIsVIPChannel').reply((config) => {
       const body = JSON.parse(config.data);
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 8df738b7..356d5021 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -130,6 +130,10 @@ export async function addNameSvrAddr(namesrvAddr: string): 
Promise<void> {
   await client.post('/ops/addNameSvrAddr', { namesrvAddr });
 }
 
+export async function deleteNameSvrAddr(namesrvAddr: string): Promise<void> {
+  await client.post('/ops/deleteNameSvrAddr', { namesrvAddr });
+}
+
 export async function updateIsVIPChannel(useVIPChannel: boolean): 
Promise<void> {
   await client.post('/ops/updateIsVIPChannel', { useVIPChannel });
 }
diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx
index 60e08ff0..b55ce118 100644
--- a/web/src/pages/studio/Ops.tsx
+++ b/web/src/pages/studio/Ops.tsx
@@ -16,12 +16,13 @@
  */
 
 import React, { useEffect, useState } from 'react';
-import { App, Button, Input, Select, Space, Switch, Typography } from 'antd';
-import { FloppyDisk, Plus } from '@phosphor-icons/react';
+import { App, Button, Input, Popconfirm, Select, Space, Switch, Tooltip, 
Typography } from 'antd';
+import { FloppyDisk, Plus, Trash } from '@phosphor-icons/react';
 import { useLang } from '../../i18n/LangContext';
 import useAuthStore from '../../stores/authStore';
 import {
   addNameSvrAddr,
+  deleteNameSvrAddr,
   queryOpsHomePage,
   updateIsVIPChannel,
   updateNameSvrAddr,
@@ -37,10 +38,13 @@ const OpsPage: React.FC = () => {
 
   const [namesrvAddrList, setNamesrvAddrList] = useState<string[]>([]);
   const [selectedNamesrv, setSelectedNamesrv] = useState('');
+  const [currentNamesrv, setCurrentNamesrv] = useState('');
   const [newNamesrvAddr, setNewNamesrvAddr] = useState('');
   const [useVIPChannel, setUseVIPChannel] = useState(false);
   const [useTLS, setUseTLS] = useState(false);
   const writeOperationEnabled = !token || admin === true;
+  const deleteNameServerDisabled =
+    !selectedNamesrv || selectedNamesrv === currentNamesrv || 
namesrvAddrList.length <= 1;
 
   useEffect(() => {
     let cancelled = false;
@@ -53,6 +57,7 @@ const OpsPage: React.FC = () => {
           setUseVIPChannel(data.useVIPChannel);
           setUseTLS(data.useTLS);
           setSelectedNamesrv(data.currentNamesrv);
+          setCurrentNamesrv(data.currentNamesrv);
         }
       } catch {
         if (!cancelled) {
@@ -75,6 +80,18 @@ const OpsPage: React.FC = () => {
     }
     try {
       await updateNameSvrAddr(selectedNamesrv);
+      setCurrentNamesrv(selectedNamesrv);
+      message.success(t('common.success'));
+    } catch {
+      message.error(t('common.failure'));
+    }
+  };
+
+  const handleDeleteNameSvrAddr = async () => {
+    try {
+      await deleteNameSvrAddr(selectedNamesrv);
+      setNamesrvAddrList((addresses) => addresses.filter((addr) => addr !== 
selectedNamesrv));
+      setSelectedNamesrv(currentNamesrv);
       message.success(t('common.success'));
     } catch {
       message.error(t('common.failure'));
@@ -144,6 +161,24 @@ const OpsPage: React.FC = () => {
               {t('common.update')}
             </Button>
           )}
+          {writeOperationEnabled && (
+            <Popconfirm
+              title={t('common.areYouSureToDelete')}
+              onConfirm={handleDeleteNameSvrAddr}
+              okText={t('common.confirm')}
+              cancelText={t('common.cancel')}
+              disabled={deleteNameServerDisabled}
+            >
+              <Tooltip title={t('common.delete')}>
+                <Button
+                  danger
+                  aria-label={t('common.delete')}
+                  icon={<Trash size={16} />}
+                  disabled={deleteNameServerDisabled}
+                />
+              </Tooltip>
+            </Popconfirm>
+          )}
           {writeOperationEnabled && (
             <Space.Compact>
               <Input
diff --git a/web/src/pages/studio/__tests__/Ops.test.tsx 
b/web/src/pages/studio/__tests__/Ops.test.tsx
index 1e41136a..30388b91 100644
--- a/web/src/pages/studio/__tests__/Ops.test.tsx
+++ b/web/src/pages/studio/__tests__/Ops.test.tsx
@@ -17,15 +17,17 @@
 
 import type { ReactElement } from 'react';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import OpsPage from '../Ops';
-import { queryOpsHomePage } from '../../../api/ops';
+import { deleteNameSvrAddr, queryOpsHomePage } from '../../../api/ops';
 import useAuthStore from '../../../stores/authStore';
 
 vi.mock('../../../api/ops', () => ({
   addNameSvrAddr: vi.fn(),
+  deleteNameSvrAddr: vi.fn(),
   queryOpsHomePage: vi.fn(),
   updateIsVIPChannel: vi.fn(),
   updateNameSvrAddr: vi.fn(),
@@ -107,4 +109,44 @@ describe('OpsPage', () => {
     expect(await 
screen.findByPlaceholderText('NamesrvAddr')).toBeInTheDocument();
     expect(screen.getByRole('button', { name: /新增|添加/ })).toBeInTheDocument();
   });
+
+  it('deletes a non-current NameServer and restores the current selection', 
async () => {
+    const user = userEvent.setup();
+    const { container } = renderWithProviders(<OpsPage />);
+
+    await waitFor(() => {
+      expect(queryOpsHomePage).toHaveBeenCalledTimes(1);
+    });
+
+    const deleteButton = screen.getByRole('button', { name: /删除|Delete/ });
+    expect(deleteButton).toBeDisabled();
+
+    fireEvent.mouseDown(container.querySelector('.ant-select-selector') as 
Element);
+    fireEvent.click(
+      await screen.findByText('127.0.0.2:9876', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    await waitFor(() => {
+      expect(deleteButton).toBeEnabled();
+    });
+
+    await user.click(deleteButton);
+    await user.click(await screen.findByRole('button', { name: /确\s*认|Confirm/ 
}));
+
+    await waitFor(() => {
+      expect(deleteNameSvrAddr).toHaveBeenCalledWith('127.0.0.2:9876');
+    });
+    
expect(container.querySelector('.ant-select-selection-item')).toHaveTextContent(
+      '127.0.0.1:9876',
+    );
+    fireEvent.mouseDown(container.querySelector('.ant-select-selector') as 
Element);
+    await waitFor(() => {
+      expect(
+        screen.queryAllByText('127.0.0.2:9876', {
+          selector: '.ant-select-item-option-content',
+        }),
+      ).toHaveLength(0);
+    });
+  });
 });

Reply via email to