This is an automated email from the ASF dual-hosted git repository.

jianbin pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/incubator-seata.git


The following commit(s) were added to refs/heads/2.x by this push:
     new 5219ca3fa2 feature: console supports creation and modification of 
transaction groups for Raft clusters (#7878)
5219ca3fa2 is described below

commit 5219ca3fa28297a2d66accb4253e9cbb48468ed1
Author: funkye <[email protected]>
AuthorDate: Mon Dec 22 11:24:56 2025 +0800

    feature: console supports creation and modification of transaction groups 
for Raft clusters (#7878)
---
 changes/en-us/2.x.md                               |   1 +
 changes/zh-cn/2.x.md                               |   1 +
 .../main/resources/static/console-fe/src/app.tsx   |   2 +-
 .../static/console-fe/src/locales/en-us.ts         |   1 +
 .../static/console-fe/src/locales/zh-cn.ts         |   1 +
 .../resources/static/console-fe/src/module.d.ts    |   2 +-
 .../src/pages/ClusterManager/ClusterManager.tsx    |  28 +++-
 .../src/pages/GlobalLockInfo/GlobalLockInfo.tsx    |  32 ++--
 .../src/pages/TransactionInfo/TransactionInfo.tsx  | 182 ++++++++++++++++-----
 .../static/console-fe/src/reducers/locale.ts       |   2 +-
 .../console-fe/src/service/clusterManager.ts       |  18 ++
 .../console-fe/src/service/transactionInfo.ts      |  27 +--
 .../namingserver/entity/bo/NamespaceData.java      |  53 ++++++
 .../seata/namingserver/entity/vo/v2/ClusterVO.java |  59 +++++++
 .../namingserver/entity/vo/v2/NamespaceVO.java     |  15 +-
 .../seata/namingserver/manager/NamingManager.java  | 101 ++++++++----
 namingserver/src/main/resources/application.yml    |   1 -
 .../seata/namingserver/NamingControllerV2Test.java |  12 +-
 .../seata/namingserver/NamingManagerTest.java      |  12 +-
 .../server/controller/VGroupMappingController.java |  15 +-
 .../controller/VGroupMappingControllerTest.java    | 118 ++++++++++++-
 21 files changed, 538 insertions(+), 145 deletions(-)

diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index feb62120c4..8c8f7184a1 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -34,6 +34,7 @@ Add changes here for all PR submitted to the 2.x branch.
 - [[#7826](https://github.com/apache/incubator-seata/pull/7826)] Support 
HTTP/2 response handling for the Watch API in Server Raft mode
 - [[#7870](https://github.com/apache/incubator-seata/pull/7870)] upgrade the 
namingserver and console modules to JDK 25 and SpringBoot 3.5, and add the 
spring-ai dependency in the console module.
 - [[#7872](https://github.com/apache/incubator-seata/pull/7872)] Automatically 
calculate the values for JVM parameters
+- [[#7878](https://github.com/apache/incubator-seata/pull/7878)] console 
supports creation and modification of transaction groups for Raft clusters
 
 
 ### bugfix:
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index f1da97fd36..37e62b42fa 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -34,6 +34,7 @@
 - [[#7826](https://github.com/apache/incubator-seata/pull/7826)] 在 Server Raft 
模式下,为 Watch API 提供对 HTTP/2 响应处理的支持
 - [[#7870](https://github.com/apache/incubator-seata/pull/7870)] 
将namingserver和console模块升级到JDK 25和SpringBoot 3.5, 并在console模块中添加spring-ai依赖。
 - [[#7872](https://github.com/apache/incubator-seata/pull/7872)] 根据当前内存值自动计算 
JVM 参数
+- [[#7878](https://github.com/apache/incubator-seata/pull/7878)] 
控制台支持raft集群模式的事务分组管理
 
 
 ### bugfix:
diff --git a/console/src/main/resources/static/console-fe/src/app.tsx 
b/console/src/main/resources/static/console-fe/src/app.tsx
index eca78192cf..c64664e3bf 100644
--- a/console/src/main/resources/static/console-fe/src/app.tsx
+++ b/console/src/main/resources/static/console-fe/src/app.tsx
@@ -71,7 +71,7 @@ class App extends React.Component<AppPropsType, AppStateType> 
{
 
   getVersion = () => {
     fetch('version.json').then(response =>
-      response.json().then(json => this.setState({ ...this.state, version: 
json.version }))
+      response.json().then(json => this.setState(prevState => ({ ...prevState, 
version: json.version })))
     );
   };
 
diff --git a/console/src/main/resources/static/console-fe/src/locales/en-us.ts 
b/console/src/main/resources/static/console-fe/src/locales/en-us.ts
index b3fae1eb64..2ea2c9f66b 100644
--- a/console/src/main/resources/static/console-fe/src/locales/en-us.ts
+++ b/console/src/main/resources/static/console-fe/src/locales/en-us.ts
@@ -130,6 +130,7 @@ const enUs: ILocale = {
     weight: 'Weight',
     healthy: 'Healthy',
     term: 'Term',
+    role: 'Role',
     unit: 'Unit',
     operations: 'Operations',
     internal: 'Internal',
diff --git a/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts 
b/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts
index 4673a6d6f8..c4b4f652f2 100644
--- a/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts
+++ b/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts
@@ -130,6 +130,7 @@ const zhCn: ILocale = {
     weight: '权重',
     healthy: '健康状态',
     term: '任期',
+    role: '角色',
     unit: '单元',
     operations: '操作',
     internal: '内部地址',
diff --git a/console/src/main/resources/static/console-fe/src/module.d.ts 
b/console/src/main/resources/static/console-fe/src/module.d.ts
index c32f1f19e8..b1f68b6f71 100644
--- a/console/src/main/resources/static/console-fe/src/module.d.ts
+++ b/console/src/main/resources/static/console-fe/src/module.d.ts
@@ -30,6 +30,6 @@ declare module '*.svg' {
 declare module 'lodash';
 
 export interface GlobalProps {
-  locale: ILocale;
+  locale: any;
   history: History;
 }
diff --git 
a/console/src/main/resources/static/console-fe/src/pages/ClusterManager/ClusterManager.tsx
 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/ClusterManager.tsx
index 0b38957762..ca625aa531 100644
--- 
a/console/src/main/resources/static/console-fe/src/pages/ClusterManager/ClusterManager.tsx
+++ 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/ClusterManager.tsx
@@ -52,10 +52,11 @@ type ClusterManagerLocale = {
   controlEndpoint?: string;
   transactionEndpoint?: string;
   metadataDialogTitle?: string;
+  role?: string;
 };
 
 type ClusterManagerState = {
-  namespaceOptions: Map<string, { clusters: string[], clusterVgroups: {[key: 
string]: string[]} }>;
+  namespaceOptions: Map<string, { clusters: string[], clusterVgroups: {[key: 
string]: string[]}, clusterTypes: {[key: string]: string} }>;
   clusters: Array<string>;
   namespace?: string;
   cluster?: string;
@@ -76,7 +77,7 @@ class ClusterManager extends React.Component<GlobalProps, 
ClusterManagerState> {
   };
 
   state: ClusterManagerState = {
-    namespaceOptions: new Map<string, { clusters: string[], clusterVgroups: 
{[key: string]: string[]} }>(),
+    namespaceOptions: new Map<string, { clusters: string[], clusterVgroups: 
{[key: string]: string[]}, clusterTypes: {[key: string]: string} }>(),
     clusters: [],
     clusterData: null,
     loading: false,
@@ -94,14 +95,22 @@ class ClusterManager extends React.Component<GlobalProps, 
ClusterManagerState> {
   loadNamespaces = async () => {
     try {
       const namespaces = await fetchNamespaceV2();
-      const namespaceOptions = new Map<string, { clusters: string[], 
clusterVgroups: {[key: string]: string[]} }>();
+      const namespaceOptions = new Map<string, { clusters: string[], 
clusterVgroups: {[key: string]: string[]}, clusterTypes: {[key: string]: 
string} }>();
       Object.keys(namespaces).forEach(namespaceKey => {
         const namespaceData = namespaces[namespaceKey];
-        const clusterVgroups: {[key: string]: string[]} = 
namespaceData.clusterVgroups || {};
-        const clusters = Object.keys(clusterVgroups);
+        const clustersData = namespaceData.clusters || {};
+        const clusterVgroups: {[key: string]: string[]} = {};
+        const clusterTypes: {[key: string]: string} = {};
+        Object.keys(clustersData).forEach(clusterName => {
+          const cluster = clustersData[clusterName];
+          clusterVgroups[clusterName] = cluster.vgroups || [];
+          clusterTypes[clusterName] = cluster.type || 'default';
+        });
+        const clusters = Object.keys(clustersData);
         namespaceOptions.set(namespaceKey, {
           clusters,
           clusterVgroups,
+          clusterTypes,
         });
       });
       if (namespaceOptions.size > 0) {
@@ -215,11 +224,13 @@ class ClusterManager extends React.Component<GlobalProps, 
ClusterManagerState> {
   };
 
   render() {
-    const { locale = {} } = this.props;
+    const { locale } = this.props;
     const rawLocale = locale.ClusterManager;
     const clusterManagerLocale: ClusterManagerLocale = typeof rawLocale === 
'object' && rawLocale !== null ? rawLocale : {};
-    const { title, subTitle, selectNamespaceFilerPlaceholder, 
selectClusterFilerPlaceholder, searchButtonLabel, unitName, members, 
clusterType, view, unitDialogTitle, control, transaction, weight, healthy, 
term, unit, operations, internal, version, metadata, controlEndpoint, 
transactionEndpoint, metadataDialogTitle } = clusterManagerLocale;
+    const { title, subTitle, selectNamespaceFilerPlaceholder, 
selectClusterFilerPlaceholder, searchButtonLabel, members, clusterType, view, 
unitDialogTitle, control, transaction, weight, healthy, term, unit, operations, 
internal, version, metadata, controlEndpoint, transactionEndpoint, 
metadataDialogTitle, role } = clusterManagerLocale;
     const unitData = this.state.clusterData ? 
Object.entries(this.state.clusterData.unitData || {}) : [];
+    const { namespace } = this.state;
+    const namespaceData = namespace ? 
this.state.namespaceOptions.get(namespace) : null;
     return (
       <Page
         title={title || 'Cluster Manager'}
@@ -264,7 +275,7 @@ class ClusterManager extends React.Component<GlobalProps, 
ClusterManagerState> {
           </FormItem>
         </Form>
         {/* unit table */}
-        <div>
+        <div style={{ marginTop: '20px' }}>
           <Table dataSource={unitData} loading={this.state.loading}>
             <Table.Column title={members || 'Members'} dataIndex="1" 
cell={(val: any) => (val.namingInstanceList ? val.namingInstanceList.length : 
0)} />
             <Table.Column title={clusterType || 'Cluster Type'} cell={() => 
(this.state.clusterData ? this.state.clusterData.clusterType : '')} />
@@ -292,6 +303,7 @@ class ClusterManager extends React.Component<GlobalProps, 
ClusterManagerState> {
             <Table.Column title={weight || 'Weight'} dataIndex="weight" />
             <Table.Column title={healthy || 'Healthy'} dataIndex="healthy" 
cell={(val: boolean) => (val ? 'Yes' : 'No')} />
             <Table.Column title={term || 'Term'} dataIndex="term" />
+            <Table.Column title={role || 'Role'} dataIndex="role" />
             <Table.Column title={unit || 'Unit'} dataIndex="unit" />
             <Table.Column title={version || 'Version'} dataIndex="version" />
             <Table.Column title={metadata || 'Metadata'} dataIndex="metadata" 
cell={(val: any) => (val ? <Button onClick={() => 
this.showMetadataDialog(val)}>View JSON</Button> : '')} />
diff --git 
a/console/src/main/resources/static/console-fe/src/pages/GlobalLockInfo/GlobalLockInfo.tsx
 
b/console/src/main/resources/static/console-fe/src/pages/GlobalLockInfo/GlobalLockInfo.tsx
index 075483aaf4..1dd04d0ba5 100644
--- 
a/console/src/main/resources/static/console-fe/src/pages/GlobalLockInfo/GlobalLockInfo.tsx
+++ 
b/console/src/main/resources/static/console-fe/src/pages/GlobalLockInfo/GlobalLockInfo.tsx
@@ -30,6 +30,7 @@ import {
 } from '@alicloud/console-components';
 import Actions from '@alicloud/console-components-actions';
 import { withRouter } from 'react-router-dom';
+import { connect } from 'react-redux';
 import Page from '@/components/Page';
 import { GlobalProps } from '@/module';
 import getData, {checkData, deleteData, GlobalLockParam } from 
'@/service/globalLockInfo';
@@ -105,8 +106,12 @@ class GlobalLockInfo extends React.Component<GlobalProps, 
GlobalLockInfoState> {
       const namespaceOptions = new Map<string, { clusters: string[], 
clusterVgroups: {[key: string]: string[]} }>();
       Object.keys(namespaces).forEach(namespaceKey => {
         const namespaceData = namespaces[namespaceKey];
-        const clusterVgroups = (namespaceData.clusterVgroups || {}) as {[key: 
string]: string[]};
-        const clusters = Object.keys(clusterVgroups);
+        const clustersData = namespaceData.clusters || {};
+        const clusterVgroups: {[key: string]: string[]} = {};
+        Object.keys(clustersData).forEach(clusterName => {
+          clusterVgroups[clusterName] = clustersData[clusterName].vgroups || 
[];
+        });
+        const clusters = Object.keys(clustersData);
         namespaceOptions.set(namespaceKey, {
           clusters,
           clusterVgroups,
@@ -149,15 +154,15 @@ class GlobalLockInfo extends React.Component<GlobalProps, 
GlobalLockInfoState> {
     }
   }
   resetSearchFilter = () => {
-    this.setState({
+    this.setState(prevState => ({
       globalLockParam: {
         // pagination info don`t reset
-        pageSize: this.state.globalLockParam.pageSize,
-        pageNum: this.state.globalLockParam.pageNum,
+        pageSize: prevState.globalLockParam.pageSize,
+        pageNum: prevState.globalLockParam.pageNum,
       },
       clusters: [],
       vgroups: [],
-    });
+    }));
   }
 
   search = () => {
@@ -276,10 +281,10 @@ class GlobalLockInfo extends React.Component<GlobalProps, 
GlobalLockInfoState> {
   }
 
   deleteCell = (val: string, index: number, record: any) => {
-    const {locale = {}} = this.props;
+    const { locale } = this.props;
     const {
       deleteGlobalLockTitle
-    } = locale;
+    } = locale.GlobalLockInfo || {};
     let width = getCurrentLanguage() === enUsKey ? '120px' : '80px'
     return (
       <Actions style={{width: width}}>
@@ -316,7 +321,8 @@ class GlobalLockInfo extends React.Component<GlobalProps, 
GlobalLockInfoState> {
 
 
   render() {
-    const { locale = {} } = this.props;
+    const { locale } = this.props;
+    const globalLockInfo = locale.GlobalLockInfo || {};
     const { title, subTitle, createTimeLabel,
       inputFilterPlaceholder,
       selectNamespaceFilerPlaceholder,
@@ -325,7 +331,7 @@ class GlobalLockInfo extends React.Component<GlobalProps, 
GlobalLockInfoState> {
       searchButtonLabel,
       resetButtonLabel,
       operateTitle,
-    } = locale;
+    } = globalLockInfo;
     return (
       <Page
         title={title}
@@ -453,4 +459,8 @@ class GlobalLockInfo extends React.Component<GlobalProps, 
GlobalLockInfoState> {
   }
 }
 
-export default withRouter(ConfigProvider.config(GlobalLockInfo, {}));
+const mapStateToProps = (state: any) => ({
+  locale: state.locale.locale,
+});
+
+export default 
ConfigProvider.config(withRouter(connect(mapStateToProps)(GlobalLockInfo)), {});
diff --git 
a/console/src/main/resources/static/console-fe/src/pages/TransactionInfo/TransactionInfo.tsx
 
b/console/src/main/resources/static/console-fe/src/pages/TransactionInfo/TransactionInfo.tsx
index 1b329a229a..5445347161 100644
--- 
a/console/src/main/resources/static/console-fe/src/pages/TransactionInfo/TransactionInfo.tsx
+++ 
b/console/src/main/resources/static/console-fe/src/pages/TransactionInfo/TransactionInfo.tsx
@@ -18,11 +18,11 @@ import React from 'react';
 import { ConfigProvider, Table, Button, DatePicker, Form, Icon, Switch, 
Pagination, Dialog, Input, Select, Message } from 
'@alicloud/console-components';
 import Actions, { LinkButton } from '@alicloud/console-components-actions';
 import { withRouter } from 'react-router-dom';
+import { connect } from 'react-redux';
 import Page from '@/components/Page';
 import { GlobalProps } from '@/module';
 import getData, { changeGlobalData, deleteBranchData, deleteGlobalData, 
GlobalSessionParam, sendGlobalCommitOrRollback,
   startBranchData, startGlobalData, stopBranchData, stopGlobalData, 
forceDeleteGlobalData, forceDeleteBranchData, fetchNamespaceV2, addGroup, 
changeGroup } from '@/service/transactionInfo';
-import PropTypes from 'prop-types';
 import moment from 'moment';
 
 import './index.scss';
@@ -47,7 +47,7 @@ type TransactionInfoState = {
   xid : string;
   currentBranchSession: Array<any>;
   globalSessionParam : GlobalSessionParam;
-  namespaceOptions: Map<string, { clusters: string[], clusterVgroups: {[key: 
string]: string[]} }>;
+  namespaceOptions: Map<string, NamespaceData>;
   clusters: Array<string>;
   vgroups: Array<string>;
   createVGroupDialogVisible: boolean;
@@ -57,14 +57,20 @@ type TransactionInfoState = {
   targetNamespace: string;
   targetClusters: Array<string>;
   targetCluster: string;
+  targetUnits: Array<string>;
+  targetUnit: string;
   originalNamespace: string;
   originalClusters: Array<string>;
   originalCluster: string;
   originalVGroups: Array<string>;
   createNamespace: string;
   createCluster: string;
+  createUnits: Array<string>;
+  createUnit: string;
 }
 
+type NamespaceData = { clusters: string[], clusterVgroups: {[key: string]: 
string[]}, clusterUnits: {[key: string]: string[]}, clusterTypes: {[key: 
string]: string} };
+
 const statusList:Array<StatusType> = [
   {
     label: 'AsyncCommitting',
@@ -305,16 +311,11 @@ const warnning = new Map([
     ['SAGA', 'The force delete will only delete session in server.']])],
 ])
 
-const VGROUP_REFRESH_DELAY_MS = 5000;
+const VGROUP_REFRESH_DELAY_MS = 6000;
 
 class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState> {
   static displayName = 'TransactionInfo';
 
-  static propTypes = {
-    locale: PropTypes.object,
-    history: PropTypes.object,
-  };
-
   state: TransactionInfoState = {
     list: [],
     total: 0,
@@ -327,7 +328,7 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
       pageSize: 10,
       pageNum: 1,
     },
-    namespaceOptions: new Map<string, { clusters: string[], clusterVgroups: 
{[key: string]: string[]} }>(),
+    namespaceOptions: new Map<string, NamespaceData>(),
     clusters: [],
     vgroups: [],
     createVGroupDialogVisible: false,
@@ -337,12 +338,16 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
     targetNamespace: '',
     targetClusters: [],
     targetCluster: '',
+    targetUnits: [],
+    targetUnit: '',
     originalNamespace: '',
     originalClusters: [],
     originalCluster: '',
     originalVGroups: [],
     createNamespace: '',
     createCluster: '',
+    createUnits: [],
+    createUnit: '',
   };
   componentDidMount = () => {
     // search once by default
@@ -351,14 +356,26 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
   loadNamespaces = async () => {
     try {
       const namespaces = await fetchNamespaceV2();
-      const namespaceOptions = new Map<string, { clusters: string[], 
clusterVgroups: {[key: string]: string[]} }>();
+      const namespaceOptions = new Map<string, NamespaceData>();
+
       Object.keys(namespaces).forEach(namespaceKey => {
         const namespaceData = namespaces[namespaceKey];
-        const clusterVgroups = (namespaceData.clusterVgroups || {}) as {[key: 
string]: string[]};
-        const clusters = Object.keys(clusterVgroups);
+        const clustersData = namespaceData.clusters || {};
+        const clusterVgroups: {[key: string]: string[]} = {};
+        const clusterUnits: {[key: string]: string[]} = {};
+        const clusterTypes: {[key: string]: string} = {};
+        Object.keys(clustersData).forEach(clusterName => {
+          const cluster = clustersData[clusterName];
+          clusterVgroups[clusterName] = cluster.vgroups || [];
+          clusterUnits[clusterName] = cluster.units || [];
+          clusterTypes[clusterName] = cluster.type || 'default';
+        });
+        const clusters = Object.keys(clustersData);
         namespaceOptions.set(namespaceKey, {
           clusters,
           clusterVgroups,
+          clusterUnits,
+          clusterTypes,
         });
       });
         if (namespaceOptions.size > 0) {
@@ -403,6 +420,8 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
 
   search = () => {
     this.setState({ loading: true });
+    const currentBranchSessionDialogVisible = 
this.state.branchSessionDialogVisible;
+    const currentXid = this.state.xid;
     getData(this.state.globalSessionParam).then(data => {
       // if the result set is empty, set the page number to go back to the 
first page
       if (data.total === 0) {
@@ -429,8 +448,8 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
         }
       });
 
-      if (this.state.branchSessionDialogVisible) {
-        const currentBranchSession = data.data.find((item: any) => item.xid == 
this.state.xid)?.branchSessionVOs || [];
+      if (currentBranchSessionDialogVisible) {
+        const currentBranchSession = data.data.find((item: any) => item.xid == 
currentXid)?.branchSessionVOs || [];
         this.setState({
           list: data.data,
           total: data.total,
@@ -535,7 +554,7 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
   }
 
   operateCell = (val: string, index: number, record: any) => {
-    const { locale = {}, history } = this.props;
+    const { locale, history } = this.props;
     const {
       showBranchSessionTitle,
       showGlobalLockTitle,
@@ -545,7 +564,7 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
       startGlobalSessionTitle,
       sendGlobalSessionTitle,
       changeGlobalSessionTitle,
-    } = locale;
+    } = locale?.TransactionInfo || {};
     let width = getCurrentLanguage() === enUsKey ? '450px' : '420px'
     let height = '120px';
     return (
@@ -729,14 +748,14 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
   }
 
   branchSessionDialogOperateCell = (val: string, index: number, record: any) 
=> {
-    const { locale = {}, history } = this.props;
+    const { locale, history } = this.props;
     const {
       showGlobalLockTitle,
       deleteBranchSessionTitle,
       stopBranchSessionTitle,
       startBranchSessionTitle,
       forceDeleteBranchSessionTitle,
-    } = locale;
+    } = locale.TransactionInfo || {};
     let width = getCurrentLanguage() === enUsKey ? '500px' : '450px'
     let height = '120px';
     return (
@@ -892,12 +911,18 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
   }
 
   showCreateVGroupDialog = () => {
-    this.setState(prevState => ({
-      createVGroupDialogVisible: true,
-      vGroupName: '',
-      createNamespace: prevState.globalSessionParam.namespace || '',
-      createCluster: prevState.globalSessionParam.cluster || '',
-    }));
+    this.setState(prevState => {
+      const clusterUnits = prevState.globalSessionParam.namespace ? 
prevState.namespaceOptions.get(prevState.globalSessionParam.namespace)?.clusterUnits
 || {} : {};
+      const units = prevState.globalSessionParam.cluster ? 
clusterUnits[prevState.globalSessionParam.cluster] || [] : [];
+      return {
+        createVGroupDialogVisible: true,
+        vGroupName: '',
+        createNamespace: prevState.globalSessionParam.namespace || '',
+        createCluster: prevState.globalSessionParam.cluster || '',
+        createUnits: units,
+        createUnit: units.length > 0 ? units[0] : '',
+      };
+    });
   }
 
   closeCreateVGroupDialog = () => {
@@ -906,6 +931,8 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
       vGroupName: '',
       createNamespace: '',
       createCluster: '',
+      createUnits: [],
+      createUnit: '',
     });
   }
 
@@ -916,6 +943,8 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
       targetNamespace: '',
       targetClusters: [],
       targetCluster: '',
+      targetUnits: [],
+      targetUnit: '',
       originalNamespace: '',
       originalClusters: [],
       originalCluster: '',
@@ -930,6 +959,8 @@ class TransactionInfo extends React.Component<GlobalProps, 
TransactionInfoState>
       targetNamespace: '',
       targetClusters: [],
       targetCluster: '',
+      targetUnits: [],
+      targetUnit: '',
       originalNamespace: '',
       originalClusters: [],
       originalCluster: '',
@@ -938,14 +969,18 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
   }
 
   handleCreateVGroup = () => {
-    const { locale = {} } = this.props;
-    const { createVGroupErrorMessage, createVGroupSuccessMessage, 
createVGroupFailMessage } = locale;
-    const { createNamespace, createCluster, vGroupName } = this.state;
+    const { locale } = this.props;
+    const { createVGroupErrorMessage, createVGroupSuccessMessage, 
createVGroupFailMessage } = locale.TransactionInfo || {};
+    const { createNamespace, createCluster, vGroupName, createUnit } = 
this.state;
     if (!createNamespace || !createCluster || !vGroupName.trim()) {
       Message.error(createVGroupErrorMessage);
       return;
     }
-    addGroup(createNamespace, createCluster, vGroupName.trim()).then(() => {
+
+    const clusterType = 
this.state.namespaceOptions.get(createNamespace)?.clusterTypes[createCluster];
+    const unitName = clusterType !== 'default' ? createUnit : '';
+
+    addGroup(createNamespace, createCluster, vGroupName.trim(), 
unitName).then(() => {
       Message.success(createVGroupSuccessMessage);
       this.closeCreateVGroupDialog();
       // Delay 5 seconds before reloading namespaces to get the latest vgroup 
list
@@ -959,14 +994,18 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
     });
   }
 
-  handleChangeVGroup = () => {
-    const { locale = {} } = this.props;
-    const { changeVGroupSuccessMessage, changeVGroupFailMessage } = locale;
-    const { selectedVGroup, targetNamespace, targetCluster } = this.state;
+    handleChangeVGroup = () => {
+    const { locale } = this.props;
+    const { changeVGroupSuccessMessage, changeVGroupFailMessage } = 
locale.TransactionInfo || {};
+    const { selectedVGroup, targetNamespace, targetCluster, targetUnit } = 
this.state;
     if (!selectedVGroup || !targetNamespace || !targetCluster) {
       return;
     }
-    changeGroup(targetNamespace, targetCluster, selectedVGroup, "").then(() => 
{
+
+    const targetClusterType = 
this.state.namespaceOptions.get(targetNamespace)?.clusterTypes[targetCluster];
+    const unitName = targetClusterType !== 'default' ? targetUnit : '';
+
+    changeGroup(targetNamespace, targetCluster, selectedVGroup, 
unitName).then(() => {
       Message.success(changeVGroupSuccessMessage);
       this.closeChangeVGroupDialog();
       // Delay 5 seconds before reloading namespaces to get the latest vgroup 
list
@@ -978,10 +1017,20 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
       const displayMessage = backendMessage ? `${changeVGroupFailMessage}: 
${backendMessage}` : changeVGroupFailMessage;
       Message.error(displayMessage);
     });
+    }
+
+  isChangeVGroupDisabled = (): boolean => {
+    const { selectedVGroup, targetNamespace, targetCluster, targetUnit, 
namespaceOptions } = this.state;
+    if (!selectedVGroup || !targetNamespace || !targetCluster) {
+      return true;
+    }
+    const clusterType = 
namespaceOptions.get(targetNamespace)?.clusterTypes[targetCluster];
+    return clusterType !== 'default' && !targetUnit;
   }
 
   render() {
-    const { locale = {} } = this.props;
+    const { locale } = this.props;
+    const transactionInfo = locale.TransactionInfo || {};
     const { title, subTitle, createTimeLabel,
       selectFilerPlaceholder,
       selectNamespaceFilerPlaceholder,
@@ -1013,7 +1062,7 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
       selectTargetNamespacePlaceholder,
       selectTargetClusterPlaceholder,
       selectVGroupPlaceholder,
-    } = locale;
+    } = transactionInfo;
     return (
       <Page
         title={title}
@@ -1189,9 +1238,13 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
                 onChange={(value: string) => {
                   this.setState(prevState => {
                     const clusters = value ? 
prevState.namespaceOptions.get(value)?.clusters || [] : [];
+                    const clusterUnits = 
prevState.namespaceOptions.get(value)?.clusterUnits || {};
+                    const units = clusters.length > 0 ? 
clusterUnits[clusters[0]] || [] : [];
                     return {
                       createNamespace: value,
                       createCluster: clusters.length > 0 ? clusters[0] : '',
+                      createUnits: units,
+                      createUnit: units.length > 0 ? units[0] : '',
                     };
                   });
                 }}
@@ -1203,12 +1256,33 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
               <Select
                 placeholder={selectClusterFilerPlaceholder}
                 onChange={(value: string) => {
-                  this.setState({ createCluster: value });
+                  this.setState(prevState => {
+                    const namespaceData = 
prevState.namespaceOptions.get(prevState.createNamespace);
+                    const clusterUnits = namespaceData ? 
namespaceData.clusterUnits : {};
+                    const units = clusterUnits[value] || [];
+                    return {
+                      createCluster: value,
+                      createUnits: units,
+                      createUnit: units.length > 0 ? units[0] : '',
+                    };
+                  });
                 }}
                 
dataSource={this.state.namespaceOptions.get(this.state.createNamespace)?.clusters.map(value
 => ({ label: value, value })) || []}
                 value={this.state.createCluster}
               />
             </FormItem>
+            
{this.state.namespaceOptions.get(this.state.createNamespace)?.clusterTypes[this.state.createCluster]
 !== 'default' && (
+              <FormItem name="createUnit" label="Unit">
+                <Select
+                  placeholder="Select unit"
+                  onChange={(value: string) => {
+                    this.setState({ createUnit: value });
+                  }}
+                  dataSource={this.state.createUnits.map(value => ({ label: 
value, value }))}
+                  value={this.state.createUnit}
+                />
+              </FormItem>
+            )}
             <FormItem name="vGroupName" label={vGroupNameLabel}>
               <Input
                 placeholder={createVGroupInputPlaceholder}
@@ -1290,7 +1364,9 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
                     return {
                       targetNamespace: value,
                       targetClusters: clusters,
-                      targetCluster: clusters.length > 0 ? clusters[0] : '',
+                      targetCluster: '',
+                      targetUnits: [],
+                      targetUnit: '',
                     };
                   });
                 }}
@@ -1303,14 +1379,36 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
                 hasClear
                 placeholder={selectTargetClusterPlaceholder}
                 onChange={(value: string) => {
-                  this.setState({ targetCluster: value });
+                  this.setState(prevState => {
+                    const namespaceData = 
prevState.namespaceOptions.get(prevState.targetNamespace);
+                    const clusterUnits = namespaceData ? 
namespaceData.clusterUnits : {};
+                    const units = clusterUnits[value] || [];
+                    return {
+                      targetCluster: value,
+                      targetUnits: units,
+                      targetUnit: units.length > 0 ? units[0] : '',
+                    };
+                  });
                 }}
                 dataSource={this.state.targetClusters.map(value => ({ label: 
value, value }))}
                 value={this.state.targetCluster}
               />
             </FormItem>
+            {this.state.targetCluster && 
this.state.namespaceOptions.get(this.state.targetNamespace)?.clusterTypes[this.state.targetCluster]
 !== 'default' && (
+              <FormItem name="targetUnit" label="Target Unit">
+                <Select
+                  hasClear
+                  placeholder="Select Target Unit"
+                  onChange={(value: string) => {
+                    this.setState({ targetUnit: value });
+                  }}
+                  dataSource={this.state.targetUnits.map(value => ({ label: 
value, value }))}
+                  value={this.state.targetUnit}
+                />
+              </FormItem>
+            )}
             <FormItem>
-              <Button type="primary" onClick={this.handleChangeVGroup} 
disabled={!this.state.selectedVGroup || !this.state.targetNamespace || 
!this.state.targetCluster}>
+              <Button type="primary" onClick={this.handleChangeVGroup} 
disabled={this.isChangeVGroupDisabled()}>
                 {confirmButtonLabel}
               </Button>
             </FormItem>
@@ -1321,4 +1419,8 @@ class TransactionInfo extends 
React.Component<GlobalProps, TransactionInfoState>
   }
 }
 
-export default withRouter(ConfigProvider.config(TransactionInfo, {}));
+const mapStateToProps = (state: any) => ({
+  locale: state.locale.locale,
+});
+
+export default 
ConfigProvider.config(withRouter(connect(mapStateToProps)(TransactionInfo)), 
{});
diff --git 
a/console/src/main/resources/static/console-fe/src/reducers/locale.ts 
b/console/src/main/resources/static/console-fe/src/reducers/locale.ts
index 6e8f7dec64..23afb708e6 100644
--- a/console/src/main/resources/static/console-fe/src/reducers/locale.ts
+++ b/console/src/main/resources/static/console-fe/src/reducers/locale.ts
@@ -52,7 +52,7 @@ const getCurrentLanguage = (): string => {
   return lang;
 }
 
-const getCurrentLocaleObj = (): any => {
+const getCurrentLocaleObj = (): ILocale => {
   let lang = getCurrentLanguage();
 
   return lang === zhCnKey ? zhCN : enUS;
diff --git 
a/console/src/main/resources/static/console-fe/src/service/clusterManager.ts 
b/console/src/main/resources/static/console-fe/src/service/clusterManager.ts
index fc1a890720..b5f8a6e8f3 100644
--- a/console/src/main/resources/static/console-fe/src/service/clusterManager.ts
+++ b/console/src/main/resources/static/console-fe/src/service/clusterManager.ts
@@ -16,6 +16,7 @@
  */
 import requestV2 from '@/utils/requestV2';
 import request from '@/utils/request';
+import qs from 'qs';
 
 export async function fetchNamespaceV2(): Promise<any> {
   const result = await requestV2.get('/naming/namespace', {
@@ -31,3 +32,20 @@ export async function fetchClusterData(namespace: string, 
clusterName: string):
   });
   return result;
 }
+
+export async function postChangeGroup(
+  namespace: string,
+  clusterName: string,
+  vGroup: string,
+  unitName: string = '',
+): Promise<any> {
+  const params = { namespace, clusterName, unitName, vGroup };
+  const result = await request.post('/naming/changeGroup', 
qs.stringify(params), {
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+  });
+  return result;
+}
+
+export async function changeGroup(namespace: string, clusterName: string, 
vGroup: string, unitName: string = ''): Promise<any> {
+  return postChangeGroup(namespace, clusterName, vGroup, unitName);
+}
diff --git 
a/console/src/main/resources/static/console-fe/src/service/transactionInfo.ts 
b/console/src/main/resources/static/console-fe/src/service/transactionInfo.ts
index da99ed5958..ca293cb772 100644
--- 
a/console/src/main/resources/static/console-fe/src/service/transactionInfo.ts
+++ 
b/console/src/main/resources/static/console-fe/src/service/transactionInfo.ts
@@ -16,6 +16,7 @@
  */
 import request from '@/utils/request';
 import requestV2 from '@/utils/requestV2';
+import qs from 'qs';
 
 export type GlobalSessionParam = {
   xid?: string,
@@ -248,28 +249,18 @@ export async function startBranchData(params: 
BranchSessionParam): Promise<any>
   return result;
 }
 
-export async function addGroup(namespace: string, clusterName: string, vGroup: 
string, unitName?: string): Promise<any> {
-  let result = await request('/naming/addGroup', {
-    method: 'POST',
-    params: {
-      namespace,
-      clusterName,
-      vGroup,
-      unitName,
-    },
+export async function addGroup(namespace: string, clusterName: string, vGroup: 
string, unitName: string = ''): Promise<any> {
+  const params = { namespace, clusterName, unitName, vGroup };
+  const result = await request.post('/naming/addGroup', qs.stringify(params), {
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
   });
   return result;
 }
 
-export async function changeGroup(namespace: string, clusterName: string, 
vGroup: string, unitName?: string): Promise<any> {
-  let result = await request('/naming/changeGroup', {
-    method: 'POST',
-    params: {
-      namespace,
-      clusterName,
-      vGroup,
-      unitName,
-    },
+export async function changeGroup(namespace: string, clusterName: string, 
vGroup: string, unitName: string = ''): Promise<any> {
+  const params = { namespace, clusterName, unitName, vGroup };
+  const result = await request.post('/naming/changeGroup', 
qs.stringify(params), {
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
   });
   return result;
 }
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/entity/bo/NamespaceData.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/bo/NamespaceData.java
new file mode 100644
index 0000000000..cbf2c07a8b
--- /dev/null
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/bo/NamespaceData.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver.entity.bo;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Business Object representing collected namespace data for v2 API.
+ * <p>
+ * This class encapsulates the collected data for namespaces, including
+ * clusters, vgroups, and their mappings for both RAFT and non-RAFT clusters.
+ */
+public class NamespaceData {
+    private final Map<String, Set<String>> clustersMap;
+    private final Map<String, Set<String>> vgroupsMap;
+    private final Map<String, Map<String, Set<String>>> clusterVgroupsMap;
+
+    public NamespaceData(
+            Map<String, Set<String>> clustersMap,
+            Map<String, Set<String>> vgroupsMap,
+            Map<String, Map<String, Set<String>>> clusterVgroupsMap) {
+        this.clustersMap = clustersMap;
+        this.vgroupsMap = vgroupsMap;
+        this.clusterVgroupsMap = clusterVgroupsMap;
+    }
+
+    public Map<String, Set<String>> getClustersMap() {
+        return clustersMap;
+    }
+
+    public Map<String, Set<String>> getVgroupsMap() {
+        return vgroupsMap;
+    }
+
+    public Map<String, Map<String, Set<String>>> getClusterVgroupsMap() {
+        return clusterVgroupsMap;
+    }
+}
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/ClusterVO.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/ClusterVO.java
new file mode 100644
index 0000000000..f322611251
--- /dev/null
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/ClusterVO.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver.entity.vo.v2;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Value Object representing cluster information for the v2 API.
+ * <p>
+ * This class encapsulates the information for a single cluster, including
+ * its associated vgroups, units, and cluster type.
+ */
+public class ClusterVO {
+
+    private List<String> vgroups = new ArrayList<>();
+
+    private List<String> units = new ArrayList<>();
+
+    private String type = "default";
+
+    public List<String> getVgroups() {
+        return vgroups;
+    }
+
+    public void setVgroups(List<String> vgroups) {
+        this.vgroups = vgroups;
+    }
+
+    public List<String> getUnits() {
+        return units;
+    }
+
+    public void setUnits(List<String> units) {
+        this.units = units;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+}
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/NamespaceVO.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/NamespaceVO.java
index 24019eb134..22e1ec6716 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/NamespaceVO.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/vo/v2/NamespaceVO.java
@@ -17,20 +17,19 @@
 package org.apache.seata.namingserver.entity.vo.v2;
 
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 
 /**
  * Value Object representing namespace information for the v2 API.
  * <p>
- * This class provides a mapping between cluster names and their associated 
vgroup lists.
+ * This class provides a mapping between cluster names and their associated 
cluster information.
  * It is used in the v2 version of the API and may differ from the original 
{@code NamespaceVO}
  * (in the v1 or root package) in its structure or the semantics of its fields.
  * <p>
  * <b>Differences from the original NamespaceVO:</b>
  * <ul>
  *   <li>Located in the {@code org.apache.seata.namingserver.entity.vo.v2} 
package, indicating it is for the v2 API.</li>
- *   <li>Contains a {@code clusterVgroups} map, which maps cluster names to 
lists of vgroup names.</li>
+ *   <li>Contains a {@code clusters} map, which maps cluster names to {@link 
ClusterVO} objects containing detailed cluster information.</li>
  *   <li>May have a different structure or additional fields compared to the 
original version.</li>
  * </ul>
  * <p>
@@ -39,13 +38,13 @@ import java.util.Map;
  */
 public class NamespaceVO {
 
-    private Map<String, List<String>> clusterVgroups = new HashMap<>();
+    private Map<String, ClusterVO> clusters = new HashMap<>();
 
-    public Map<String, List<String>> getClusterVgroups() {
-        return clusterVgroups;
+    public Map<String, ClusterVO> getClusters() {
+        return clusters;
     }
 
-    public void setClusterVgroups(Map<String, List<String>> clusterVgroups) {
-        this.clusterVgroups = clusterVgroups;
+    public void setClusters(Map<String, ClusterVO> clusters) {
+        this.clusters = clusters;
     }
 }
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
index eced4cb15d..a738d229a0 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
@@ -36,6 +36,7 @@ import org.apache.seata.common.util.HttpClientUtil;
 import org.apache.seata.common.util.StringUtils;
 import org.apache.seata.namingserver.entity.bo.ClusterBO;
 import org.apache.seata.namingserver.entity.bo.NamespaceBO;
+import org.apache.seata.namingserver.entity.bo.NamespaceData;
 import org.apache.seata.namingserver.entity.pojo.ClusterData;
 import org.apache.seata.namingserver.entity.vo.NamespaceVO;
 import org.apache.seata.namingserver.entity.vo.monitor.ClusterVO;
@@ -79,22 +80,6 @@ public class NamingManager {
     private final ConcurrentMap<String /* namespace */, ConcurrentMap<String 
/* clusterName */, ClusterData>>
             namespaceClusterDataMap;
 
-    // Helper class to hold collected namespace data
-    private static class NamespaceData {
-        final Map<String, Set<String>> clustersMap;
-        final Map<String, Set<String>> vgroupsMap;
-        final Map<String, Map<String, Set<String>>> clusterVgroupsMap;
-
-        NamespaceData(
-                Map<String, Set<String>> clustersMap,
-                Map<String, Set<String>> vgroupsMap,
-                Map<String, Map<String, Set<String>>> clusterVgroupsMap) {
-            this.clustersMap = clustersMap;
-            this.vgroupsMap = vgroupsMap;
-            this.clusterVgroupsMap = clusterVgroupsMap;
-        }
-    }
-
     @Value("${heartbeat.threshold:90000}")
     private int heartbeatTimeThreshold;
 
@@ -182,6 +167,25 @@ public class NamingManager {
 
     public Result<String> createGroup(
             String namespace, String vGroup, String clusterName, String 
unitName, boolean checkExist) {
+        // If unitName is blank, find it from cluster
+        String actualUnitName = unitName;
+        if (StringUtils.isBlank(unitName)) {
+            Map<String, ClusterData> clusterDataMap = 
namespaceClusterDataMap.get(namespace);
+            if (clusterDataMap != null) {
+                ClusterData clusterData = clusterDataMap.get(clusterName);
+                if (clusterData != null && 
!CollectionUtils.isEmpty(clusterData.getUnitData())) {
+                    Optional<Map.Entry<String, Unit>> optionalEntry =
+                            
clusterData.getUnitData().entrySet().stream().findFirst();
+                    if (optionalEntry.isPresent()) {
+                        actualUnitName = optionalEntry.get().getKey();
+                    }
+                }
+            }
+        }
+        if (StringUtils.isBlank(actualUnitName)) {
+            LOGGER.error("no available unit for namespace {} and cluster {}", 
namespace, clusterName);
+            return new Result<>("400", "no available unit for cluster: " + 
clusterName);
+        }
         // Check if vGroup already exists
         if (checkExist && vGroupMap.getIfPresent(vGroup) != null) {
             LOGGER.error("vGroup {} already exists", vGroup);
@@ -206,7 +210,7 @@ public class NamingManager {
                     + NamingServerConstants.HTTP_ADD_GROUP_SUFFIX;
             HashMap<String, String> params = new HashMap<>();
             params.put(CONSTANT_GROUP, vGroup);
-            params.put(NamingServerConstants.CONSTANT_UNIT, unitName);
+            params.put(NamingServerConstants.CONSTANT_UNIT, actualUnitName);
             Map<String, String> header = new HashMap<>();
             header.put(HTTP.CONTENT_TYPE, 
ContentType.APPLICATION_FORM_URLENCODED.getMimeType());
 
@@ -474,6 +478,11 @@ public class NamingManager {
     public Result<String> changeGroup(String namespace, String vGroup, String 
clusterName, String unitName) {
         long changeTime = System.currentTimeMillis();
         ConcurrentMap<String, NamespaceBO> namespaceMap = new 
ConcurrentHashMap<>(vGroupMap.get(vGroup));
+        Result<String> res = createGroup(namespace, vGroup, clusterName, 
unitName, false);
+        if (!res.isSuccess()) {
+            LOGGER.error("add vgroup failed! {}", res.getMessage());
+            return res;
+        }
         Set<String> currentNamespaces = namespaceMap.keySet();
         Map<String, Set<String>> namespaceClusters = new HashMap<>();
         for (String currentNamespace : currentNamespaces) {
@@ -482,11 +491,6 @@ public class NamingManager {
                     new HashSet<>(
                             
namespaceMap.get(currentNamespace).getClusterMap().keySet()));
         }
-        Result<String> res = createGroup(namespace, vGroup, clusterName, 
unitName, false);
-        if (!res.isSuccess()) {
-            LOGGER.error("add vgroup failed!" + res.getMessage());
-            return res;
-        }
         AtomicReference<Result<String>> result = new AtomicReference<>();
         namespaceClusters.forEach((oldNamespace, clusters) -> {
             for (String cluster : clusters) {
@@ -500,7 +504,8 @@ public class NamingManager {
                                 if (optionalEntry.isPresent()) {
                                     String unit = optionalEntry.get().getKey();
                                     Unit unitData = 
optionalEntry.get().getValue();
-                                    result.set(removeGroup(unitData, vGroup, 
cluster, oldNamespace, unitName));
+                                    result.set(removeGroup(
+                                            unitData, vGroup, cluster, 
oldNamespace, unitData.getUnitName()));
                                     notifyClusterChange(vGroup, namespace, 
cluster, unit, changeTime);
                                 }
                             }
@@ -515,16 +520,16 @@ public class NamingManager {
         NamespaceData data = collectNamespaceData();
 
         Map<String, NamespaceVO> namespaceVOs = new HashMap<>();
-        if (data.vgroupsMap.isEmpty()) {
-            data.clustersMap.forEach((namespace, clusters) -> {
+        if (data.getVgroupsMap().isEmpty()) {
+            data.getClustersMap().forEach((namespace, clusters) -> {
                 NamespaceVO namespaceVO = new NamespaceVO();
                 namespaceVO.setClusters(new ArrayList<>(clusters));
                 namespaceVOs.put(namespace, namespaceVO);
             });
         } else {
-            data.vgroupsMap.forEach((namespace, vgroups) -> {
+            data.getVgroupsMap().forEach((namespace, vgroups) -> {
                 NamespaceVO namespaceVO = 
namespaceVOs.computeIfAbsent(namespace, k -> new NamespaceVO());
-                Set<String> clusters = data.clustersMap.get(namespace);
+                Set<String> clusters = data.getClustersMap().get(namespace);
                 namespaceVO.setClusters(new ArrayList<>(clusters != null ? 
clusters : Collections.emptyList()));
                 namespaceVO.setVgroups(new ArrayList<>(vgroups));
             });
@@ -539,17 +544,41 @@ public class NamingManager {
 
         // Build NamespaceVOv2
         Map<String, org.apache.seata.namingserver.entity.vo.v2.NamespaceVO> 
namespaceVOs = new HashMap<>();
-        data.clustersMap.forEach((namespace, clusters) -> {
+        data.getClustersMap().forEach((namespace, clusters) -> {
             org.apache.seata.namingserver.entity.vo.v2.NamespaceVO namespaceVO 
=
                     new 
org.apache.seata.namingserver.entity.vo.v2.NamespaceVO();
-            Map<String, List<String>> clusterVgList = new HashMap<>();
-            Map<String, Set<String>> clusterVgSet = 
data.clusterVgroupsMap.get(namespace);
+            Map<String, org.apache.seata.namingserver.entity.vo.v2.ClusterVO> 
clusterVOMap = new HashMap<>();
+
             clusters.forEach(cluster -> {
-                Set<String> vgSet = clusterVgSet.get(cluster);
-                clusterVgList.put(cluster, vgSet != null ? new 
ArrayList<>(vgSet) : new ArrayList<>());
+                org.apache.seata.namingserver.entity.vo.v2.ClusterVO clusterVO 
=
+                        new 
org.apache.seata.namingserver.entity.vo.v2.ClusterVO();
+
+                // Set units and type for this cluster
+                Map<String, ClusterData> clusterDataMap = 
namespaceClusterDataMap.get(namespace);
+                String clusterType = "default";
+                List<String> unitNames = new ArrayList<>();
+                if (clusterDataMap != null) {
+                    ClusterData clusterData = clusterDataMap.get(cluster);
+                    if (clusterData != null) {
+                        unitNames = clusterData.getUnitData().values().stream()
+                                .map(Unit::getUnitName)
+                                .collect(Collectors.toList());
+                        clusterType = clusterData.getClusterType();
+                        clusterVO.setType(clusterType);
+                    }
+                }
+                clusterVO.setUnits(unitNames);
+
+                // Set vgroups (same logic for all cluster types)
+                Map<String, Set<String>> clusterVgSet =
+                        data.getClusterVgroupsMap().get(namespace);
+                Set<String> vgSet = clusterVgSet != null ? 
clusterVgSet.get(cluster) : null;
+                clusterVO.setVgroups(vgSet != null ? new ArrayList<>(vgSet) : 
new ArrayList<>());
+
+                clusterVOMap.put(cluster, clusterVO);
             });
-            namespaceVO.setClusterVgroups(clusterVgList);
 
+            namespaceVO.setClusters(clusterVOMap);
             namespaceVOs.put(namespace, namespaceVO);
         });
 
@@ -563,8 +592,7 @@ public class NamingManager {
         Map<String, Map<String, Set<String>>> clusterVgroupsMap = new 
HashMap<>(); // namespace -> cluster -> vgroups
 
         // Collect all namespaces
-        Set<String> allNamespaces = new HashSet<>();
-        allNamespaces.addAll(namespaceClusterDataMap.keySet());
+        Set<String> allNamespaces = new 
HashSet<>(namespaceClusterDataMap.keySet());
         vGroupMap.asMap().values().forEach(namespaceMap -> 
allNamespaces.addAll(namespaceMap.keySet()));
 
         // Initialize maps for all namespaces
@@ -587,8 +615,9 @@ public class NamingManager {
             Set<String> vgroups = vgroupsMap.get(namespace);
             vgroups.add(vGroup);
 
-            // Build cluster to vgroups
+            // Build cluster to vgroups mapping
             Map<String, Set<String>> clusterVg = 
clusterVgroupsMap.get(namespace);
+
             namespaceBO.getClusterMap().forEach((clusterName, clusterBO) -> {
                 Set<String> vgSet = clusterVg.computeIfAbsent(clusterName, k 
-> new HashSet<>());
                 vgSet.add(vGroup);
diff --git a/namingserver/src/main/resources/application.yml 
b/namingserver/src/main/resources/application.yml
index f1a54facf3..ea243e2401 100644
--- a/namingserver/src/main/resources/application.yml
+++ b/namingserver/src/main/resources/application.yml
@@ -14,7 +14,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-
 server:
   port: 8081
 spring:
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
index a3ebf87832..81b1985a8d 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
@@ -75,9 +75,15 @@ class NamingControllerV2Test {
 
         NamespaceVO namespaceVO = result.getData().get(namespace);
         assertNotNull(namespaceVO);
-        assertNotNull(namespaceVO.getClusterVgroups());
-        assertTrue(namespaceVO.getClusterVgroups().containsKey(clusterName));
-        
assertTrue(namespaceVO.getClusterVgroups().get(clusterName).contains(vGroup));
+        assertNotNull(namespaceVO.getClusters());
+        assertTrue(namespaceVO.getClusters().containsKey(clusterName));
+        org.apache.seata.namingserver.entity.vo.v2.ClusterVO clusterVO =
+                namespaceVO.getClusters().get(clusterName);
+        assertNotNull(clusterVO);
+        assertNotNull(clusterVO.getVgroups());
+        assertTrue(clusterVO.getVgroups().contains(vGroup));
+        assertNotNull(clusterVO.getUnits());
+        assertTrue(clusterVO.getUnits().contains(unitName));
 
         // Clean up
         namingManager.unregisterInstance(namespace, clusterName, unitName, 
node);
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
index 1191cfc230..f50c84d638 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
@@ -383,9 +383,15 @@ class NamingManagerTest {
         org.apache.seata.namingserver.entity.vo.v2.NamespaceVO namespaceVO =
                 result.getData().get(namespace);
         assertNotNull(namespaceVO);
-        assertNotNull(namespaceVO.getClusterVgroups());
-        assertTrue(namespaceVO.getClusterVgroups().containsKey(clusterName));
-        
assertTrue(namespaceVO.getClusterVgroups().get(clusterName).contains(vGroup));
+        assertNotNull(namespaceVO.getClusters());
+        assertTrue(namespaceVO.getClusters().containsKey(clusterName));
+        org.apache.seata.namingserver.entity.vo.v2.ClusterVO clusterVO =
+                namespaceVO.getClusters().get(clusterName);
+        assertNotNull(clusterVO);
+        assertNotNull(clusterVO.getVgroups());
+        assertTrue(clusterVO.getVgroups().contains(vGroup));
+        assertNotNull(clusterVO.getUnits());
+        assertTrue(clusterVO.getUnits().contains(unitName));
     }
 
     @Test
diff --git 
a/server/src/main/java/org/apache/seata/server/controller/VGroupMappingController.java
 
b/server/src/main/java/org/apache/seata/server/controller/VGroupMappingController.java
index c60a3699b0..a1ca37b0da 100644
--- 
a/server/src/main/java/org/apache/seata/server/controller/VGroupMappingController.java
+++ 
b/server/src/main/java/org/apache/seata/server/controller/VGroupMappingController.java
@@ -18,11 +18,11 @@ package org.apache.seata.server.controller;
 
 import org.apache.seata.common.metadata.Instance;
 import org.apache.seata.common.result.Result;
-import org.apache.seata.config.Configuration;
-import org.apache.seata.config.ConfigurationFactory;
+import org.apache.seata.common.util.StringUtils;
 import org.apache.seata.core.store.MappingDO;
 import org.apache.seata.server.session.SessionHolder;
 import org.apache.seata.server.store.VGroupMappingStoreManager;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -34,7 +34,8 @@ public class VGroupMappingController {
 
     private VGroupMappingStoreManager vGroupMappingStoreManager;
 
-    protected static final Configuration CONFIG = 
ConfigurationFactory.getInstance();
+    @Value("${sessionMode:file}")
+    String sessionMode;
 
     /**
      * add vGroup in cluster
@@ -51,7 +52,9 @@ public class VGroupMappingController {
         mappingDO.setUnit(unit);
         mappingDO.setVGroup(vGroup);
         boolean rst = 
SessionHolder.getRootVGroupMappingManager().addVGroup(mappingDO);
-        Instance.getInstance().setTerm(System.currentTimeMillis());
+        if (!StringUtils.equalsIgnoreCase("raft", sessionMode)) {
+            Instance.getInstance().setTerm(System.currentTimeMillis());
+        }
         if (!rst) {
             result.setCode("500");
             result.setMessage("add vGroup failed!");
@@ -69,7 +72,9 @@ public class VGroupMappingController {
     public Result<?> removeVGroup(@RequestParam String vGroup) {
         Result<?> result = new Result<>();
         boolean rst = 
SessionHolder.getRootVGroupMappingManager().removeVGroup(vGroup);
-        Instance.getInstance().setTerm(System.currentTimeMillis());
+        if (!StringUtils.equalsIgnoreCase("raft", sessionMode)) {
+            Instance.getInstance().setTerm(System.currentTimeMillis());
+        }
         if (!rst) {
             result.setCode("500");
             result.setMessage("remove vGroup failed!");
diff --git 
a/server/src/test/java/org/apache/seata/server/controller/VGroupMappingControllerTest.java
 
b/server/src/test/java/org/apache/seata/server/controller/VGroupMappingControllerTest.java
index 8759159f85..0ddd708e72 100644
--- 
a/server/src/test/java/org/apache/seata/server/controller/VGroupMappingControllerTest.java
+++ 
b/server/src/test/java/org/apache/seata/server/controller/VGroupMappingControllerTest.java
@@ -16,24 +16,124 @@
  */
 package org.apache.seata.server.controller;
 
-import org.junit.jupiter.api.Disabled;
+import org.apache.seata.common.metadata.Instance;
+import org.apache.seata.common.result.Result;
+import org.apache.seata.core.store.MappingDO;
+import org.apache.seata.server.BaseSpringBootTest;
+import org.apache.seata.server.session.SessionHolder;
+import org.apache.seata.server.store.VGroupMappingStoreManager;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.boot.test.context.SpringBootTest;
 
-@Disabled
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
 @SpringBootTest
-class VGroupMappingControllerTest {
-    @Autowired
+@ExtendWith(MockitoExtension.class)
+class VGroupMappingControllerTest extends BaseSpringBootTest {
+
     private VGroupMappingController vGroupMappingController;
 
+    @Mock
+    private VGroupMappingStoreManager vGroupMappingStoreManager;
+
+    private Instance instance;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        vGroupMappingController = new VGroupMappingController();
+        instance = Instance.getInstance();
+        instance.setNamespace("default");
+        instance.setClusterName("default");
+        instance.setTerm(0);
+        setSessionMode("file");
+    }
+
+    @Test
+    void addVGroupShouldSetTerm_whenSessionModeIsNotRaft() throws Exception {
+        try (MockedStatic<SessionHolder> sessionHolderMock = 
Mockito.mockStatic(SessionHolder.class)) {
+            
when(vGroupMappingStoreManager.addVGroup(org.mockito.ArgumentMatchers.any(MappingDO.class)))
+                    .thenReturn(true);
+            
sessionHolderMock.when(SessionHolder::getRootVGroupMappingManager).thenReturn(vGroupMappingStoreManager);
+            long before = instance.getTerm();
+            Result<?> result = vGroupMappingController.addVGroup("vgroup", 
"unit-a");
+            assertEquals(Result.SUCCESS_CODE, result.getCode());
+            assertEquals(Result.SUCCESS_MSG, result.getMessage());
+            assertTrue(instance.getTerm() >= before);
+            ArgumentCaptor<MappingDO> mappingCaptor = 
ArgumentCaptor.forClass(MappingDO.class);
+            
verify(vGroupMappingStoreManager).addVGroup(mappingCaptor.capture());
+            MappingDO mappingDO = mappingCaptor.getValue();
+            assertEquals("default", mappingDO.getNamespace());
+            assertEquals("default", mappingDO.getCluster());
+            assertEquals("unit-a", mappingDO.getUnit());
+            assertEquals("vgroup", mappingDO.getVGroup());
+        }
+    }
+
     @Test
-    void addVGroup() {
-        vGroupMappingController.addVGroup("group1", "unit1");
+    void addVGroupShouldReturnErrorWhenStoreFails() throws Exception {
+        try (MockedStatic<SessionHolder> sessionHolderMock = 
Mockito.mockStatic(SessionHolder.class)) {
+            
when(vGroupMappingStoreManager.addVGroup(org.mockito.ArgumentMatchers.any(MappingDO.class)))
+                    .thenReturn(false);
+            
sessionHolderMock.when(SessionHolder::getRootVGroupMappingManager).thenReturn(vGroupMappingStoreManager);
+            Result<?> result = vGroupMappingController.addVGroup("vgroup", 
"unit-a");
+            assertEquals("500", result.getCode());
+            assertEquals("add vGroup failed!", result.getMessage());
+        }
     }
 
     @Test
-    void removeVGroup() {
-        vGroupMappingController.removeVGroup("group1");
+    void removeVGroupShouldSetTerm_whenSessionModeIsNotRaft() throws Exception 
{
+        try (MockedStatic<SessionHolder> sessionHolderMock = 
Mockito.mockStatic(SessionHolder.class)) {
+            
when(vGroupMappingStoreManager.removeVGroup("vgroup")).thenReturn(true);
+            
sessionHolderMock.when(SessionHolder::getRootVGroupMappingManager).thenReturn(vGroupMappingStoreManager);
+            long before = instance.getTerm();
+            Result<?> result = vGroupMappingController.removeVGroup("vgroup");
+            assertEquals(Result.SUCCESS_CODE, result.getCode());
+            assertEquals(Result.SUCCESS_MSG, result.getMessage());
+            assertTrue(instance.getTerm() >= before);
+            verify(vGroupMappingStoreManager).removeVGroup("vgroup");
+        }
+    }
+
+    @Test
+    void removeVGroupShouldReturnErrorWhenStoreFails() throws Exception {
+        try (MockedStatic<SessionHolder> sessionHolderMock = 
Mockito.mockStatic(SessionHolder.class)) {
+            
when(vGroupMappingStoreManager.removeVGroup("vgroup")).thenReturn(false);
+            
sessionHolderMock.when(SessionHolder::getRootVGroupMappingManager).thenReturn(vGroupMappingStoreManager);
+            Result<?> result = vGroupMappingController.removeVGroup("vgroup");
+            assertEquals("500", result.getCode());
+            assertEquals("remove vGroup failed!", result.getMessage());
+        }
+    }
+
+    @Test
+    void addVGroupShouldNotModifyTermWhenRaft() throws Exception {
+        setSessionMode("raft");
+        try (MockedStatic<SessionHolder> sessionHolderMock = 
Mockito.mockStatic(SessionHolder.class)) {
+            
when(vGroupMappingStoreManager.addVGroup(org.mockito.ArgumentMatchers.any(MappingDO.class)))
+                    .thenReturn(true);
+            
sessionHolderMock.when(SessionHolder::getRootVGroupMappingManager).thenReturn(vGroupMappingStoreManager);
+            long before = instance.getTerm();
+            vGroupMappingController.addVGroup("vgroup", "unit-a");
+            assertEquals(before, instance.getTerm());
+        }
+    }
+
+    private void setSessionMode(String mode) throws Exception {
+        Field sessionModeField = 
VGroupMappingController.class.getDeclaredField("sessionMode");
+        sessionModeField.setAccessible(true);
+        sessionModeField.set(vGroupMappingController, mode);
     }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to