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 41242611ab feature: support displaying cluster information in the 
console (#7857)
41242611ab is described below

commit 41242611abc2de8c6bf09f9e496c4fb06f15af57
Author: funkye <[email protected]>
AuthorDate: Tue Dec 16 22:09:28 2025 +0800

    feature: support displaying cluster information in the console (#7857)
---
 changes/en-us/2.x.md                               |   1 +
 changes/zh-cn/2.x.md                               |   3 +-
 .../org/apache/seata/common/metadata/Instance.java |  21 ++
 .../metadata/namingserver/NamingServerNode.java    |   3 +-
 .../common/metadata/namingserver/InstanceTest.java |   3 +
 .../namingserver/NamingServerNodeTest.java         |   3 +
 .../main/resources/static/console-fe/src/app.tsx   |   6 +-
 .../static/console-fe/src/locales/en-us.ts         |  28 +-
 .../static/console-fe/src/locales/index.d.ts       |   1 +
 .../static/console-fe/src/locales/zh-cn.ts         |  26 ++
 .../resources/static/console-fe/src/module.d.ts    |   4 +-
 .../src/pages/ClusterManager/ClusterManager.tsx    | 314 +++++++++++++++++++++
 .../ClusterManager/index.scss}                     |   4 -
 .../requestV2.ts => pages/ClusterManager/index.ts} |   5 +-
 .../resources/static/console-fe/src/router.tsx     |   2 +
 .../src/{module.d.ts => service/clusterManager.ts} |  28 +-
 .../static/console-fe/src/utils/request.ts         |   4 +-
 .../static/console-fe/src/utils/requestV2.ts       |   2 +-
 .../namingserver/controller/NamingController.java  |  11 +
 .../namingserver/entity/pojo/ClusterData.java      |   2 +
 .../seata/namingserver/manager/NamingManager.java  |  11 +-
 namingserver/src/main/resources/application.yml    |   1 -
 .../seata/namingserver/NamingControllerTest.java   |  45 +++
 .../seata/namingserver/NamingManagerTest.java      |  41 ++-
 .../instance/AbstractSeataInstanceStrategy.java    |   2 +
 .../instance/RaftServerInstanceStrategy.java       |   5 +
 26 files changed, 538 insertions(+), 38 deletions(-)

diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index 2ead074bdf..adab1edbab 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -30,6 +30,7 @@ Add changes here for all PR submitted to the 2.x branch.
 - [[#7675](https://github.com/apache/incubator-seata/pull/7675)] support 
Oracle Batch Insert
 - [[#7663](https://github.com/apache/incubator-seata/pull/7663)] add Java 25 
support in CI configuration files
 - [[#7851](https://github.com/apache/incubator-seata/pull/7851)] add support 
for managing transaction groups
+- [[#7857](https://github.com/apache/incubator-seata/pull/7857)] support 
displaying cluster information in the console
 - [[#7826](https://github.com/apache/incubator-seata/pull/7826)] Support 
HTTP/2 response handling for the Watch API in Server Raft mode
 
 
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index ebe102dc83..eb12b12194 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -28,8 +28,9 @@
 - [[#7669](https://github.com/apache/incubator-seata/pull/7669)] 添加对 Jackson 
序列化和反序列化 PostgreSQL 数组类型的支持
 - [[#7664](https://github.com/apache/incubator-seata/pull/7565)] 支持神通数据库的XA模式
 - [[#7675](https://github.com/apache/incubator-seata/pull/7675)] 支持Oracle批量插入
-- [[#7663](https://github.com/apache/incubator-seata/pull/7663)] 
支持java25版本的CI流水綫
+- [[#7663](https://github.com/apache/incubator-seata/pull/7663)] 
支持java25版本的CI流水线
 - [[#7851](https://github.com/apache/incubator-seata/pull/7851)] 控制台支持事务分组管理能力
+- [[#7857](https://github.com/apache/incubator-seata/pull/7857)] 控制台支持集群信息展示
 - [[#7826](https://github.com/apache/incubator-seata/pull/7826)] 在 Server Raft 
模式下,为 Watch API 提供对 HTTP/2 响应处理的支持
 
 
diff --git 
a/common/src/main/java/org/apache/seata/common/metadata/Instance.java 
b/common/src/main/java/org/apache/seata/common/metadata/Instance.java
index 2862587d71..7acc4672da 100644
--- a/common/src/main/java/org/apache/seata/common/metadata/Instance.java
+++ b/common/src/main/java/org/apache/seata/common/metadata/Instance.java
@@ -31,10 +31,12 @@ public class Instance {
     private String unit;
     private Node.Endpoint control;
     private Node.Endpoint transaction;
+    private Node.Endpoint internal;
     private double weight = 1.0;
     private boolean healthy = true;
     private long term;
     private long timestamp;
+    private String version;
     private ClusterRole role = ClusterRole.MEMBER;
     private Map<String, Object> metadata = new HashMap<>();
 
@@ -165,6 +167,15 @@ public class Instance {
         }
     }
 
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    @Override
     public Instance clone() {
         Instance instance = new Instance();
         instance.setNamespace(namespace);
@@ -177,9 +188,19 @@ public class Instance {
         instance.setTerm(term);
         instance.setTimestamp(timestamp);
         instance.setMetadata(metadata);
+        instance.setVersion(this.getVersion());
+        instance.setInternal(this.getInternal());
         return instance;
     }
 
+    public Node.Endpoint getInternal() {
+        return internal;
+    }
+
+    public void setInternal(Node.Endpoint internal) {
+        this.internal = internal;
+    }
+
     private static class SingletonHolder {
         private static final Instance SERVER_INSTANCE = new Instance();
         private static final List<Instance> SERVER_INSTANCES = new 
ArrayList<>();
diff --git 
a/common/src/main/java/org/apache/seata/common/metadata/namingserver/NamingServerNode.java
 
b/common/src/main/java/org/apache/seata/common/metadata/namingserver/NamingServerNode.java
index b22a8cb620..cdef618bb3 100644
--- 
a/common/src/main/java/org/apache/seata/common/metadata/namingserver/NamingServerNode.java
+++ 
b/common/src/main/java/org/apache/seata/common/metadata/namingserver/NamingServerNode.java
@@ -17,6 +17,7 @@
 package org.apache.seata.common.metadata.namingserver;
 
 import org.apache.seata.common.metadata.Node;
+import org.apache.seata.common.util.StringUtils;
 
 import java.util.Objects;
 
@@ -77,7 +78,7 @@ public class NamingServerNode extends Node {
         NamingServerNode otherNode = (NamingServerNode) obj;
 
         // other node is newer than me
-        return otherNode.term > term;
+        return otherNode.term > term || 
!StringUtils.equals(otherNode.getVersion(), this.getVersion());
     }
 
     public void setWeight(double weight) {
diff --git 
a/common/src/test/java/org/apache/seata/common/metadata/namingserver/InstanceTest.java
 
b/common/src/test/java/org/apache/seata/common/metadata/namingserver/InstanceTest.java
index b1312aa979..eb9e5d2204 100644
--- 
a/common/src/test/java/org/apache/seata/common/metadata/namingserver/InstanceTest.java
+++ 
b/common/src/test/java/org/apache/seata/common/metadata/namingserver/InstanceTest.java
@@ -60,7 +60,10 @@ class InstanceTest {
         instance.setTimestamp(System.currentTimeMillis());
         instance.setControl(new Node.Endpoint("1.1.1.1", 888));
         instance.setTransaction(new Node.Endpoint("2.2.2.2", 999));
+        instance.setInternal(new Node.Endpoint("2.2.2.2", 1099));
         assertEquals(instance.toJsonString(objectMapper), 
objectMapper.writeValueAsString(instance));
+        assertEquals(
+                instance.clone().getInternal().getPort(), 
instance.getInternal().getPort());
     }
 
     @Test
diff --git 
a/common/src/test/java/org/apache/seata/common/metadata/namingserver/NamingServerNodeTest.java
 
b/common/src/test/java/org/apache/seata/common/metadata/namingserver/NamingServerNodeTest.java
index 1d9593c48c..e0c417a064 100644
--- 
a/common/src/test/java/org/apache/seata/common/metadata/namingserver/NamingServerNodeTest.java
+++ 
b/common/src/test/java/org/apache/seata/common/metadata/namingserver/NamingServerNodeTest.java
@@ -108,5 +108,8 @@ class NamingServerNodeTest {
         Assertions.assertTrue(currentNode.isChanged(newerNode));
         Assertions.assertFalse(currentNode.isChanged(olderNode));
         Assertions.assertFalse(currentNode.isChanged(null));
+        newerNode = new NamingServerNode();
+        newerNode.setVersion("v1");
+        Assertions.assertTrue(currentNode.isChanged(newerNode));
     }
 }
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 d3bac4a24d..eca78192cf 100644
--- a/console/src/main/resources/static/console-fe/src/app.tsx
+++ b/console/src/main/resources/static/console-fe/src/app.tsx
@@ -78,7 +78,7 @@ class App extends React.Component<AppPropsType, AppStateType> 
{
   get menu() {
     const { locale }: AppPropsType = this.props;
     const { MenuRouter = {} } = locale;
-    const { overview, transactionInfo, globalLockInfo, 
sagaStatemachineDesigner } = MenuRouter;
+    const { transactionInfo, globalLockInfo, clusterManager, 
sagaStatemachineDesigner } = MenuRouter;
     return {
       items: [
         // {
@@ -93,6 +93,10 @@ class App extends React.Component<AppPropsType, 
AppStateType> {
           key: '/globallock/list',
           label: globalLockInfo,
         },
+        {
+          key: '/cluster/list',
+          label: clusterManager,
+        },
         {
           key: '/sagastatemachinedesigner',
           label: sagaStatemachineDesigner,
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 c792ceec80..b3fae1eb64 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
@@ -22,6 +22,7 @@ const enUs: ILocale = {
     overview: 'Overview',
     transactionInfo: 'TransactionInfo',
     globalLockInfo: 'GlobalLockInfo',
+    clusterManager: 'ClusterManager',
     sagaStatemachineDesigner: 'SagaStatemachineDesigner',
   },
   Header: {
@@ -101,7 +102,7 @@ const enUs: ILocale = {
     selectVGroupPlaceholder: 'Select vgroup',
   },
   GlobalLockInfo: {
-    title: 'GlobalLockInfo',
+    title: 'Global Lock Info',
     subTitle: 'list',
     createTimeLabel: 'CreateTime',
     inputFilterPlaceholder: 'Please enter filter criteria',
@@ -113,6 +114,31 @@ const enUs: ILocale = {
     operateTitle: 'operate',
     deleteGlobalLockTitle: 'Delete global lock',
   },
+  ClusterManager: {
+    title: 'Cluster Manager',
+    subTitle: 'Cluster Node Management',
+    selectNamespaceFilerPlaceholder: 'Please select namespace',
+    selectClusterFilerPlaceholder: 'Please select cluster',
+    searchButtonLabel: 'Query',
+    unitName: 'Unit Name',
+    members: 'Members',
+    clusterType: 'Cluster Type',
+    view: 'View',
+    unitDialogTitle: 'Unit Details',
+    control: 'Control Address',
+    transaction: 'Transaction Address',
+    weight: 'Weight',
+    healthy: 'Healthy',
+    term: 'Term',
+    unit: 'Unit',
+    operations: 'Operations',
+    internal: 'Internal',
+    version: 'Version',
+    metadata: 'Metadata',
+    controlEndpoint: 'Control Endpoint',
+    transactionEndpoint: 'Transaction Endpoint',
+    metadataDialogTitle: 'Metadata',
+  },
   codeMessage: {
     200: 'The server successfully returned the requested data.',
     201: 'New or modified data successful.',
diff --git 
a/console/src/main/resources/static/console-fe/src/locales/index.d.ts 
b/console/src/main/resources/static/console-fe/src/locales/index.d.ts
index 2b43536e58..806b6f91f0 100644
--- a/console/src/main/resources/static/console-fe/src/locales/index.d.ts
+++ b/console/src/main/resources/static/console-fe/src/locales/index.d.ts
@@ -25,5 +25,6 @@ export interface ILocale {
   Overview: ILocaleMap;
   TransactionInfo: ILocaleMap;
   GlobalLockInfo: ILocaleMap;
+  ClusterManager: ILocaleMap;
   codeMessage: ILocaleMap;
 }
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 7ef23b08e3..4673a6d6f8 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
@@ -22,6 +22,7 @@ const zhCn: ILocale = {
     overview: '概览',
     transactionInfo: '事务信息',
     globalLockInfo: '全局锁信息',
+    clusterManager: '集群管理',
     sagaStatemachineDesigner: 'Saga状态机设计器',
   },
   Header: {
@@ -113,6 +114,31 @@ const zhCn: ILocale = {
     operateTitle: '操作',
     deleteGlobalLockTitle: '删除全局锁',
   },
+  ClusterManager: {
+    title: '集群管理',
+    subTitle: '集群节点管理',
+    selectNamespaceFilerPlaceholder: '请选择命名空间',
+    selectClusterFilerPlaceholder: '请选择集群',
+    searchButtonLabel: '查询',
+    unitName: '单元名称',
+    members: '成员数',
+    clusterType: '集群类型',
+    view: '查看',
+    unitDialogTitle: '单元详情',
+    control: '控制地址',
+    transaction: '事务地址',
+    weight: '权重',
+    healthy: '健康状态',
+    term: '任期',
+    unit: '单元',
+    operations: '操作',
+    internal: '内部地址',
+    version: '版本',
+    metadata: '元数据',
+    controlEndpoint: '控制端点',
+    transactionEndpoint: '事务端点',
+    metadataDialogTitle: '元数据',
+  },
   codeMessage: {
     200: '服务器成功返回请求的数据。',
     201: '新建或修改数据成功。',
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 d016f4ec0d..c32f1f19e8 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
@@ -17,7 +17,7 @@
 /// <reference types="react" />
 // tslint:disable
 import { History } from 'history';
-import { ILocaleMap } from '@/locales';
+import { ILocale, ILocaleMap } from '@/locales';
 
 declare const __mock__: boolean;
 
@@ -30,6 +30,6 @@ declare module '*.svg' {
 declare module 'lodash';
 
 export interface GlobalProps {
-  locale: ILocaleMap;
+  locale: ILocale;
   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
new file mode 100644
index 0000000000..0b38957762
--- /dev/null
+++ 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/ClusterManager.tsx
@@ -0,0 +1,314 @@
+/*
+ * 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.
+ */
+import React from 'react';
+import { ConfigProvider, Table, Button, Form, Icon, Dialog, Select, Message } 
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 { fetchNamespaceV2, fetchClusterData } from '@/service/clusterManager';
+import PropTypes from 'prop-types';
+
+import './index.scss';
+
+const FormItem = Form.Item;
+
+type ClusterManagerLocale = {
+  title?: string;
+  subTitle?: string;
+  selectNamespaceFilerPlaceholder?: string;
+  selectClusterFilerPlaceholder?: string;
+  searchButtonLabel?: string;
+  unitName?: string;
+  members?: string;
+  clusterType?: string;
+  view?: string;
+  unitDialogTitle?: string;
+  control?: string;
+  transaction?: string;
+  weight?: string;
+  healthy?: string;
+  term?: string;
+  unit?: string;
+  operations?: string;
+  internal?: string;
+  version?: string;
+  metadata?: string;
+  controlEndpoint?: string;
+  transactionEndpoint?: string;
+  metadataDialogTitle?: string;
+};
+
+type ClusterManagerState = {
+  namespaceOptions: Map<string, { clusters: string[], clusterVgroups: {[key: 
string]: string[]} }>;
+  clusters: Array<string>;
+  namespace?: string;
+  cluster?: string;
+  clusterData: any; // ClusterData
+  loading: boolean;
+  unitDialogVisible: boolean;
+  selectedUnit: any; // Unit
+  selectedUnitName: string;
+  metadataDialogVisible: boolean;
+  selectedMetadata: any;
+};
+
+class ClusterManager extends React.Component<GlobalProps, ClusterManagerState> 
{
+  static displayName = 'ClusterManager';
+
+  static propTypes = {
+    locale: PropTypes.object,
+  };
+
+  state: ClusterManagerState = {
+    namespaceOptions: new Map<string, { clusters: string[], clusterVgroups: 
{[key: string]: string[]} }>(),
+    clusters: [],
+    clusterData: null,
+    loading: false,
+    unitDialogVisible: false,
+    selectedUnit: null,
+    selectedUnitName: '',
+    metadataDialogVisible: false,
+    selectedMetadata: null,
+  };
+
+  componentDidMount = () => {
+    this.loadNamespaces();
+  };
+
+  loadNamespaces = async () => {
+    try {
+      const namespaces = await fetchNamespaceV2();
+      const namespaceOptions = new Map<string, { clusters: string[], 
clusterVgroups: {[key: string]: string[]} }>();
+      Object.keys(namespaces).forEach(namespaceKey => {
+        const namespaceData = namespaces[namespaceKey];
+        const clusterVgroups: {[key: string]: string[]} = 
namespaceData.clusterVgroups || {};
+        const clusters = Object.keys(clusterVgroups);
+        namespaceOptions.set(namespaceKey, {
+          clusters,
+          clusterVgroups,
+        });
+      });
+      if (namespaceOptions.size > 0) {
+        const firstNamespace = Array.from(namespaceOptions.keys())[0];
+        const selectedNamespace = namespaceOptions.get(firstNamespace);
+        const firstCluster = selectedNamespace ? selectedNamespace.clusters[0] 
: undefined;
+        this.setState(prevState => ({
+          ...prevState,
+          namespaceOptions,
+          namespace: firstNamespace,
+          cluster: firstCluster,
+          clusters: selectedNamespace ? selectedNamespace.clusters : [],
+        }), () => {
+          this.search();
+        });
+      } else {
+        this.setState(prevState => ({
+          ...prevState,
+          namespaceOptions,
+        }));
+      }
+    } catch (error) {
+      console.error('Failed to fetch namespaces:', error);
+    }
+  };
+
+  searchFilterOnChange = (key: string, val: string) => {
+    if (key === 'namespace') {
+      const selectedNamespace = this.state.namespaceOptions.get(val);
+      const clusters = selectedNamespace ? selectedNamespace.clusters : [];
+      const firstCluster = clusters.length > 0 ? clusters[0] : undefined;
+      this.setState(prevState => ({
+        ...prevState,
+        namespace: val,
+        cluster: firstCluster,
+        clusters,
+      }));
+    } else if (key === 'cluster') {
+      this.setState(prevState => ({
+        ...prevState,
+        cluster: val,
+      }));
+    }
+  };
+
+  search = () => {
+    const { namespace, cluster } = this.state;
+    if (!namespace || !cluster) {
+      Message.error('Please select namespace and cluster');
+      return;
+    }
+    this.setState(prevState => ({
+      ...prevState,
+      loading: true,
+    }));
+    fetchClusterData(namespace, cluster).then(data => {
+      if (data.success) {
+        this.setState(prevState => ({
+          ...prevState,
+          clusterData: data.data,
+          loading: false,
+        }));
+      } else {
+        Message.error(data.message || 'Failed to fetch cluster data');
+        this.setState(prevState => ({
+          ...prevState,
+          loading: false,
+        }));
+      }
+    }).catch(err => {
+      Message.error('Failed to fetch cluster data');
+      this.setState(prevState => ({
+        ...prevState,
+        loading: false,
+      }));
+    });
+  };
+
+  showUnitDialog = (unitName: string, unit: any) => {
+    this.setState(prevState => ({
+      ...prevState,
+      unitDialogVisible: true,
+      selectedUnit: unit,
+      selectedUnitName: unitName,
+    }));
+  };
+
+  closeUnitDialog = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      unitDialogVisible: false,
+      selectedUnit: null,
+      selectedUnitName: '',
+    }));
+  };
+
+  showMetadataDialog = (metadata: any) => {
+    this.setState(prevState => ({
+      ...prevState,
+      metadataDialogVisible: true,
+      selectedMetadata: metadata,
+    }));
+  };
+
+  closeMetadataDialog = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      metadataDialogVisible: false,
+      selectedMetadata: null,
+    }));
+  };
+
+  render() {
+    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 unitData = this.state.clusterData ? 
Object.entries(this.state.clusterData.unitData || {}) : [];
+    return (
+      <Page
+        title={title || 'Cluster Manager'}
+        breadcrumbs={[
+          {
+            link: '/',
+            text: title || 'Cluster Manager',
+          },
+          {
+            text: subTitle || 'Manage Clusters',
+          },
+        ]}
+      >
+        {/* search form */}
+        <Form inline labelAlign="left">
+          <FormItem name="namespace" label="namespace">
+            <Select
+              hasClear
+              placeholder={selectNamespaceFilerPlaceholder || 'Select 
namespace'}
+              onChange={(value: string) => {
+                this.searchFilterOnChange('namespace', value);
+              }}
+              
dataSource={Array.from(this.state.namespaceOptions.keys()).map(key => ({ label: 
key, value: key }))}
+              value={this.state.namespace}
+            />
+          </FormItem>
+          <FormItem name="cluster" label="cluster">
+            <Select
+              hasClear
+              placeholder={selectClusterFilerPlaceholder || 'Select cluster'}
+              onChange={(value: string) => {
+                this.searchFilterOnChange('cluster', value);
+              }}
+              dataSource={this.state.clusters.map(value => ({ label: value, 
value }))}
+              value={this.state.cluster}
+            />
+          </FormItem>
+          <FormItem>
+            <Form.Submit onClick={this.search}>
+              <Icon type="search" />{searchButtonLabel || 'Search'}
+            </Form.Submit>
+          </FormItem>
+        </Form>
+        {/* unit table */}
+        <div>
+          <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 : '')} />
+            <Table.Column
+              title={operations || 'Operations'}
+              cell={(val: any, index: number, record: any) => {
+                return (
+                  <Actions>
+                    <Button onClick={() => this.showUnitDialog(record[0], 
record[1])}>
+                      {view || 'View'}
+                    </Button>
+                  </Actions>
+                );
+              }}
+            />
+          </Table>
+        </div>
+
+        {/* unit dialog */}
+        <Dialog visible={this.state.unitDialogVisible} 
title={`${unitDialogTitle || 'Unit'}: ${this.state.selectedUnitName}`} 
footer={false} onClose={this.closeUnitDialog} style={{ width: '80vw', height: 
'80vh', overflow: 'auto' }}>
+          <Table dataSource={this.state.selectedUnit ? 
this.state.selectedUnit.namingInstanceList || [] : []} style={{ overflow: 
'auto' }}>
+            <Table.Column title={control || 'Control'} dataIndex="control" 
cell={(val: any) => (val ? `${controlEndpoint || 'Control Endpoint'}: 
${val.host}:${val.port}` : '')} />
+            <Table.Column title={transaction || 'Transaction'} 
dataIndex="transaction" cell={(val: any) => (val ? `${transactionEndpoint || 
'Transaction Endpoint'}: ${val.host}:${val.port}` : '')} />
+            <Table.Column title={internal || 'Internal'} dataIndex="internal" 
cell={(val: any) => (val ? `${val.host}:${val.port}` : '')} />
+            <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={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> : '')} />
+          </Table>
+        </Dialog>
+
+        {/* metadata dialog */}
+        <Dialog visible={this.state.metadataDialogVisible} 
title={metadataDialogTitle || 'Metadata'} footer={false} 
onClose={this.closeMetadataDialog} style={{ width: '80vw', height: '80vh', 
overflow: 'auto' }}>
+          <pre>{JSON.stringify(this.state.selectedMetadata, null, 2)}</pre>
+        </Dialog>
+      </Page>
+    );
+  }
+}
+
+const mapStateToProps = (state: any) => ({
+  locale: state.locale.locale,
+});
+
+export default 
connect(mapStateToProps)(withRouter(ConfigProvider.config(ClusterManager, {})));
diff --git 
a/console/src/main/resources/static/console-fe/src/utils/requestV2.ts 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/index.scss
similarity index 87%
copy from console/src/main/resources/static/console-fe/src/utils/requestV2.ts
copy to 
console/src/main/resources/static/console-fe/src/pages/ClusterManager/index.scss
index f467fc0c6b..6a9c064171 100644
--- a/console/src/main/resources/static/console-fe/src/utils/requestV2.ts
+++ 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/index.scss
@@ -14,8 +14,4 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { createRequest } from './request';
 
-const requestV2 = createRequest('api/v2');
-
-export default requestV2;
diff --git 
a/console/src/main/resources/static/console-fe/src/utils/requestV2.ts 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/index.ts
similarity index 87%
copy from console/src/main/resources/static/console-fe/src/utils/requestV2.ts
copy to 
console/src/main/resources/static/console-fe/src/pages/ClusterManager/index.ts
index f467fc0c6b..9b334587fd 100644
--- a/console/src/main/resources/static/console-fe/src/utils/requestV2.ts
+++ 
b/console/src/main/resources/static/console-fe/src/pages/ClusterManager/index.ts
@@ -14,8 +14,5 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { createRequest } from './request';
+export { default } from './ClusterManager';
 
-const requestV2 = createRequest('api/v2');
-
-export default requestV2;
diff --git a/console/src/main/resources/static/console-fe/src/router.tsx 
b/console/src/main/resources/static/console-fe/src/router.tsx
index d881b472d0..d3957a2999 100644
--- a/console/src/main/resources/static/console-fe/src/router.tsx
+++ b/console/src/main/resources/static/console-fe/src/router.tsx
@@ -18,10 +18,12 @@ import { HashRouter, Route, Switch, Redirect } from 
'react-router-dom';
 import Overview from '@/pages/Overview';
 import TransactionInfo from '@/pages/TransactionInfo';
 import GlobalLockInfo from './pages/GlobalLockInfo';
+import ClusterManager from './pages/ClusterManager';
 
 export default [
   // { path: '/', exact: true, render: () => <Redirect to="/Overview" /> },
   // { path: '/Overview', component: Overview },
   { path: '/transaction/list', component: TransactionInfo },
   { path: '/globallock/list', component: GlobalLockInfo },
+  { path: '/cluster/list', component: ClusterManager },
 ];
diff --git a/console/src/main/resources/static/console-fe/src/module.d.ts 
b/console/src/main/resources/static/console-fe/src/service/clusterManager.ts
similarity index 62%
copy from console/src/main/resources/static/console-fe/src/module.d.ts
copy to 
console/src/main/resources/static/console-fe/src/service/clusterManager.ts
index d016f4ec0d..fc1a890720 100644
--- a/console/src/main/resources/static/console-fe/src/module.d.ts
+++ b/console/src/main/resources/static/console-fe/src/service/clusterManager.ts
@@ -14,22 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-/// <reference types="react" />
-// tslint:disable
-import { History } from 'history';
-import { ILocaleMap } from '@/locales';
+import requestV2 from '@/utils/requestV2';
+import request from '@/utils/request';
 
-declare const __mock__: boolean;
-
-
-declare module '*.svg' {
-  const SvgIcon: React.ComponentClass<any>;
-  export default SvgIcon;
+export async function fetchNamespaceV2(): Promise<any> {
+  const result = await requestV2.get('/naming/namespace', {
+    method: 'get',
+  });
+  return result.data;
 }
 
-declare module 'lodash';
-
-export interface GlobalProps {
-  locale: ILocaleMap;
-  history: History;
+export async function fetchClusterData(namespace: string, clusterName: 
string): Promise<any> {
+  const result = await request.get('/naming/clusterData', {
+    method: 'get',
+    params: { namespace, clusterName },
+  });
+  return result;
 }
diff --git a/console/src/main/resources/static/console-fe/src/utils/request.ts 
b/console/src/main/resources/static/console-fe/src/utils/request.ts
index d41e2119d5..1634022fe7 100644
--- a/console/src/main/resources/static/console-fe/src/utils/request.ts
+++ b/console/src/main/resources/static/console-fe/src/utils/request.ts
@@ -38,7 +38,7 @@ const createRequest = (baseURL: string, generalErrorMessage: 
string = 'Request e
   instance.interceptors.response.use(
     (response: AxiosResponse): Promise<any> => {
       const code = get(response, 'data.code');
-      if (response.status === 200 && code === '200') {
+      if (response.status === 200 && String(code) === '200') {
         return Promise.resolve(get(response, 'data'));
       } else {
         const currentLocale = getCurrentLocaleObj();
@@ -69,7 +69,7 @@ const createRequest = (baseURL: string, generalErrorMessage: 
string = 'Request e
   return instance;
 };
 
-const request = createRequest('api/v1');
+const request = createRequest('/api/v1');
 
 export { createRequest };
 export default request;
diff --git 
a/console/src/main/resources/static/console-fe/src/utils/requestV2.ts 
b/console/src/main/resources/static/console-fe/src/utils/requestV2.ts
index f467fc0c6b..28f1dc3fa6 100644
--- a/console/src/main/resources/static/console-fe/src/utils/requestV2.ts
+++ b/console/src/main/resources/static/console-fe/src/utils/requestV2.ts
@@ -16,6 +16,6 @@
  */
 import { createRequest } from './request';
 
-const requestV2 = createRequest('api/v2');
+const requestV2 = createRequest('/api/v2');
 
 export default requestV2;
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/controller/NamingController.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/controller/NamingController.java
index a9e423cf23..f158f68265 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/controller/NamingController.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/controller/NamingController.java
@@ -20,6 +20,7 @@ import 
org.apache.seata.common.metadata.namingserver.MetaResponse;
 import org.apache.seata.common.metadata.namingserver.NamingServerNode;
 import org.apache.seata.common.result.Result;
 import org.apache.seata.common.result.SingleResult;
+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;
 import org.apache.seata.namingserver.entity.vo.monitor.WatcherVO;
@@ -109,6 +110,16 @@ public class NamingController {
         return namingManager.monitorCluster(namespace);
     }
 
+    @GetMapping("/clusterData")
+    public SingleResult<ClusterData> getClusterData(@RequestParam String 
namespace, @RequestParam String clusterName) {
+        ClusterData clusterData = namingManager.getClusterData(namespace, 
clusterName);
+        if (clusterData != null) {
+            return SingleResult.success(clusterData);
+        } else {
+            return SingleResult.failure("Cluster not found");
+        }
+    }
+
     @GetMapping("/discovery")
     public MetaResponse discovery(@RequestParam String vGroup, @RequestParam 
String namespace) {
         return new MetaResponse(
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/entity/pojo/ClusterData.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/pojo/ClusterData.java
index 823e088cde..5612512277 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/entity/pojo/ClusterData.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/entity/pojo/ClusterData.java
@@ -16,6 +16,7 @@
  */
 package org.apache.seata.namingserver.entity.pojo;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import org.apache.seata.common.metadata.Cluster;
 import org.apache.seata.common.metadata.Node;
 import org.apache.seata.common.metadata.namingserver.NamingServerNode;
@@ -97,6 +98,7 @@ public class ClusterData {
         }
     }
 
+    @JsonIgnore
     public List<Node> getInstanceList() {
         return unitData.values().stream()
                 .map(Unit::getNamingInstanceList)
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 9a159c8997..f67d8ca26b 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
@@ -168,6 +168,14 @@ public class NamingManager {
         return new ArrayList<>(clusterVOHashMap.values());
     }
 
+    public ClusterData getClusterData(String namespace, String clusterName) {
+        Map<String, ClusterData> clusterDataMap = 
namespaceClusterDataMap.get(namespace);
+        if (clusterDataMap != null) {
+            return clusterDataMap.get(clusterName);
+        }
+        return null;
+    }
+
     public Result<String> createGroup(String namespace, String vGroup, String 
clusterName, String unitName) {
         return createGroup(namespace, vGroup, clusterName, unitName, true);
     }
@@ -516,7 +524,8 @@ public class NamingManager {
         } else {
             data.vgroupsMap.forEach((namespace, vgroups) -> {
                 NamespaceVO namespaceVO = 
namespaceVOs.computeIfAbsent(namespace, k -> new NamespaceVO());
-                namespaceVO.setClusters(new 
ArrayList<>(data.clustersMap.get(namespace)));
+                Set<String> clusters = data.clustersMap.get(namespace);
+                namespaceVO.setClusters(new ArrayList<>(clusters != null ? 
clusters : Collections.emptyList()));
                 namespaceVO.setVgroups(new ArrayList<>(vgroups));
             });
         }
diff --git a/namingserver/src/main/resources/application.yml 
b/namingserver/src/main/resources/application.yml
index 0ea1c8b5d2..ab636c40cc 100644
--- a/namingserver/src/main/resources/application.yml
+++ b/namingserver/src/main/resources/application.yml
@@ -17,7 +17,6 @@
 
 server:
   port: 8081
-
 spring:
   application:
     name: seata-namingserver
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerTest.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerTest.java
index 20128d1da7..6029ee3e73 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerTest.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerTest.java
@@ -22,7 +22,9 @@ import 
org.apache.seata.common.metadata.namingserver.MetaResponse;
 import org.apache.seata.common.metadata.namingserver.NamingServerNode;
 import org.apache.seata.common.metadata.namingserver.Unit;
 import org.apache.seata.common.result.Result;
+import org.apache.seata.common.result.SingleResult;
 import org.apache.seata.namingserver.controller.NamingController;
+import org.apache.seata.namingserver.entity.pojo.ClusterData;
 import org.apache.seata.namingserver.manager.NamingManager;
 import org.junit.jupiter.api.Test;
 import org.junit.runner.RunWith;
@@ -42,6 +44,8 @@ import java.util.concurrent.TimeUnit;
 import static org.apache.seata.common.NamingServerConstants.CONSTANT_GROUP;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
 @RunWith(SpringRunner.class)
@@ -399,4 +403,45 @@ class NamingControllerTest {
             fail("Test failed due to exception: " + e.getMessage());
         }
     }
+
+    @Test
+    void testGetClusterData() {
+        String clusterName = "test-cluster";
+        String namespace = "test-namespace";
+        String unitName = String.valueOf(UUID.randomUUID());
+        NamingServerNode node = new NamingServerNode();
+        node.setTransaction(new Node.Endpoint("127.0.0.1", 8091, "netty"));
+        node.setControl(new Node.Endpoint("127.0.0.1", 7091, "http"));
+        Map<String, Object> metadata = node.getMetadata();
+        Map<String, Object> vGroups = new HashMap<>();
+        vGroups.put("test-vGroup", unitName);
+        metadata.put(CONSTANT_GROUP, vGroups);
+        namingController.registerInstance(namespace, clusterName, unitName, 
node);
+
+        SingleResult<ClusterData> result = 
namingController.getClusterData(namespace, clusterName);
+        assertNotNull(result);
+        assertEquals("200", result.getCode());
+        assertEquals("success", result.getMessage());
+        assertNotNull(result.getData());
+        ClusterData clusterData = result.getData();
+        assertEquals(clusterName, clusterData.getClusterName());
+        assertNotNull(clusterData.getUnitData());
+        assertEquals(1, clusterData.getUnitData().size());
+        assertTrue(clusterData.getUnitData().containsKey(unitName));
+        Unit unit = clusterData.getUnitData().get(unitName);
+        assertNotNull(unit);
+        assertEquals(unitName, unit.getUnitName());
+        assertNotNull(unit.getNamingInstanceList());
+        assertEquals(1, unit.getNamingInstanceList().size());
+        Node instance = unit.getNamingInstanceList().get(0);
+        assertEquals("127.0.0.1", instance.getTransaction().getHost());
+        assertEquals(8091, instance.getTransaction().getPort());
+
+        // Test non-existent cluster
+        SingleResult<ClusterData> resultNotFound = 
namingController.getClusterData(namespace, "non-existent-cluster");
+        assertNotNull(resultNotFound);
+        assertEquals("500", resultNotFound.getCode());
+        assertEquals("Cluster not found", resultNotFound.getMessage());
+        assertNull(resultNotFound.getData());
+    }
 }
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 80e26d4b3b..1191cfc230 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
@@ -26,6 +26,7 @@ import org.apache.seata.common.metadata.namingserver.Unit;
 import org.apache.seata.common.result.Result;
 import org.apache.seata.common.result.SingleResult;
 import org.apache.seata.common.util.HttpClientUtil;
+import org.apache.seata.namingserver.entity.pojo.ClusterData;
 import org.apache.seata.namingserver.entity.vo.monitor.ClusterVO;
 import org.apache.seata.namingserver.entity.vo.v2.NamespaceVO;
 import org.apache.seata.namingserver.listener.ClusterChangeEvent;
@@ -48,10 +49,7 @@ import java.util.Map;
 import java.util.UUID;
 
 import static org.apache.seata.common.NamingServerConstants.CONSTANT_GROUP;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyMap;
@@ -389,4 +387,39 @@ class NamingManagerTest {
         assertTrue(namespaceVO.getClusterVgroups().containsKey(clusterName));
         
assertTrue(namespaceVO.getClusterVgroups().get(clusterName).contains(vGroup));
     }
+
+    @Test
+    void testGetClusterData() {
+        String namespace = "test-namespace";
+        String clusterName = "test-cluster";
+        String unitName = UUID.randomUUID().toString();
+
+        NamingServerNode node = createTestNode("127.0.0.1", 8080, unitName);
+        boolean result = namingManager.registerInstance(node, namespace, 
clusterName, unitName);
+
+        assertTrue(result);
+
+        ClusterData clusterData = namingManager.getClusterData(namespace, 
clusterName);
+        assertNotNull(clusterData);
+        assertEquals(clusterName, clusterData.getClusterName());
+        assertNotNull(clusterData.getUnitData());
+        assertEquals(1, clusterData.getUnitData().size());
+        assertTrue(clusterData.getUnitData().containsKey(unitName));
+        Unit unit = clusterData.getUnitData().get(unitName);
+        assertNotNull(unit);
+        assertEquals(unitName, unit.getUnitName());
+        assertNotNull(unit.getNamingInstanceList());
+        assertEquals(1, unit.getNamingInstanceList().size());
+        Node instance = unit.getNamingInstanceList().get(0);
+        assertEquals("127.0.0.1", instance.getTransaction().getHost());
+        assertEquals(8080, instance.getTransaction().getPort());
+
+        // Test non-existent cluster
+        ClusterData notFound = namingManager.getClusterData(namespace, 
"non-existent-cluster");
+        assertNull(notFound);
+
+        // Test non-existent namespace
+        ClusterData notFoundNamespace = 
namingManager.getClusterData("non-existent-namespace", clusterName);
+        assertNull(notFoundNamespace);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/seata/server/instance/AbstractSeataInstanceStrategy.java
 
b/server/src/main/java/org/apache/seata/server/instance/AbstractSeataInstanceStrategy.java
index 329d3e1777..00d8a8eef3 100644
--- 
a/server/src/main/java/org/apache/seata/server/instance/AbstractSeataInstanceStrategy.java
+++ 
b/server/src/main/java/org/apache/seata/server/instance/AbstractSeataInstanceStrategy.java
@@ -18,6 +18,7 @@ package org.apache.seata.server.instance;
 
 import org.apache.seata.common.metadata.Instance;
 import org.apache.seata.common.thread.NamedThreadFactory;
+import org.apache.seata.core.protocol.Version;
 import org.apache.seata.server.session.SessionHolder;
 import org.apache.seata.server.store.VGroupMappingStoreManager;
 import 
org.apache.seata.spring.boot.autoconfigure.properties.registry.RegistryNamingServerProperties;
@@ -69,6 +70,7 @@ public abstract class AbstractSeataInstanceStrategy 
implements SeataInstanceStra
             return;
         }
         Instance instance = serverInstanceInit();
+        instance.setVersion(Version.getCurrent());
         if (init.compareAndSet(false, true)) {
             VGroupMappingStoreManager vGroupMappingStoreManager = 
SessionHolder.getRootVGroupMappingManager();
             // load vgroup mapping relationship
diff --git 
a/server/src/main/java/org/apache/seata/server/instance/RaftServerInstanceStrategy.java
 
b/server/src/main/java/org/apache/seata/server/instance/RaftServerInstanceStrategy.java
index 0bd1863bd1..7c20a40800 100644
--- 
a/server/src/main/java/org/apache/seata/server/instance/RaftServerInstanceStrategy.java
+++ 
b/server/src/main/java/org/apache/seata/server/instance/RaftServerInstanceStrategy.java
@@ -16,6 +16,7 @@
  */
 package org.apache.seata.server.instance;
 
+import com.alipay.sofa.jraft.entity.PeerId;
 import org.apache.seata.common.XID;
 import org.apache.seata.common.holder.ObjectHolder;
 import org.apache.seata.common.metadata.ClusterRole;
@@ -74,6 +75,10 @@ public class RaftServerInstanceStrategy extends 
AbstractSeataInstanceStrategy
         // load node Endpoint
         instance.setControl(new Node.Endpoint(XID.getIpAddress(), 
serverProperties.getPort(), "http"));
 
+        PeerId peerId =
+                
RaftServerManager.getRaftServer(raftProperties.getGroup()).getServerId();
+        instance.setInternal(new Node.Endpoint(peerId.getIp(), 
peerId.getPort(), "raft"));
+
         // load metadata
         for (PropertySource<?> propertySource : 
environment.getPropertySources()) {
             if (propertySource instanceof EnumerablePropertySource) {


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


Reply via email to