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);
+ });
+ });
});