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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 1e35b1b2 feat: expose NameServer configuration drift in the dashboard 
(#1173)
1e35b1b2 is described below

commit 1e35b1b298ccf0bf781b43fb6b9670b7246f7533
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 18:13:55 2026 +0800

    feat: expose NameServer configuration drift in the dashboard (#1173)
---
 README.md                                          |   2 +-
 docs/api-spec.md                                   |  41 +++-
 .../cluster/nameserver/NameServerController.java   |   9 +
 .../nameserver/NameServerControllerTest.java       |  42 ++++
 web/src/App.tsx                                    |   2 +
 web/src/api/cluster.test.ts                        |  31 +++
 web/src/api/cluster.ts                             |  29 +++
 web/src/i18n/translations.ts                       |  35 +++
 web/src/layouts/MainLayout.tsx                     |   7 +
 .../__tests__/NameServerConfigDriftPage.test.tsx   | 176 +++++++++++++++
 web/src/pages/ops/nameServerConfigDrift.tsx        | 236 +++++++++++++++++++++
 web/src/services/clusterService.test.ts            |  18 ++
 web/src/services/clusterService.ts                 |  37 ++++
 13 files changed, 658 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index 070ee202..03b6dc92 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ configured users; disabling login protection only skips API 
interception for loc
 |--------|--------------|
 | **Dashboard** | Global statistics for clusters, brokers, topics, and 
consumer groups with TPS trends |
 | **Instances** | Multi-instance access (Proxy / Direct mode), instance CRUD |
-| **Clusters** | Cluster details, Broker / NameServer / Proxy node operations, 
hot config updates |
+| **Clusters** | Cluster details, Broker / NameServer / Proxy node operations, 
hot config updates, NameServer configuration drift detection |
 | **K8s Certs** | TLS / mTLS / ServiceAccount certificate management and 
renewal |
 | **Topics** | Topic CRUD, route viewer, consumer list, multi-type support 
(Normal / FIFO / Delay / Transaction / Lite) |
 | **Consumer Groups** | Consumer group CRUD, consumption progress, 
subscription details, offset reset, config import/export |
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 6ed89bc1..b75f4f0d 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -541,7 +541,36 @@ POST /api/nameservers/delete
 
 **Response `data`:** `{ success: boolean }`
 
-### 4.10 重启 Proxy
+### 4.10 检查 NameServer 配置漂移
+
+```
+GET /api/nameservers/config-diff?clusterId={clusterId}
+```
+
+**Query Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `clusterId` | `string` | 是 | 要检查的集群 ID |
+
+该接口逐个读取集群内的 NameServer 
配置,只比较服务端白名单中的非敏感运行参数。完整配置、路径、密码和凭据不会返回;单个节点读取失败时仍返回其他节点的结果,并将 `complete` 标记为 
`false`。
+
+**Response `data`:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `cluster` | `string` | 集群 ID |
+| `complete` | `boolean` | 是否成功读取全部 NameServer 节点 |
+| `driftDetected` | `boolean` | 可达节点间是否存在配置差异 |
+| `nodeCount` | `number` | NameServer 节点总数 |
+| `reachableNodeCount` | `number` | 成功读取的节点数 |
+| `comparedKeys` | `string[]` | 本次比较的安全配置项 |
+| `nodes` | `NodeStatus[]` | 节点地址和可达状态 |
+| `differences` | `ConfigDifference[]` | 配置不一致的键及各节点值 |
+
+`differences[].values[].configured` 用于区分未配置和已配置为空值;`value` 仅包含白名单配置项的值。
+
+### 4.11 重启 Proxy
 
 ```
 POST /api/proxies/restart
@@ -555,7 +584,7 @@ POST /api/proxies/restart
 
 **Response `data`:** `{ success: boolean }`
 
-### 4.11 获取 K8s 证书列表
+### 4.12 获取 K8s 证书列表
 
 ```
 GET /api/k8s-certs
@@ -577,7 +606,7 @@ GET /api/k8s-certs
 | `daysRemaining` | `number` | 剩余天数 |
 | `san` | `string[]` | Subject Alternative Name 列表 |
 
-### 4.12 添加 K8s 证书
+### 4.13 添加 K8s 证书
 
 ```
 POST /api/k8s-certs/create
@@ -595,7 +624,7 @@ POST /api/k8s-certs/create
 
 **Response `data`:** `K8sCertInfo`
 
-### 4.13 更新 K8s 证书
+### 4.14 更新 K8s 证书
 
 ```
 POST /api/k8s-certs/update
@@ -614,7 +643,7 @@ POST /api/k8s-certs/update
 
 **Response `data`:** `K8sCertInfo`
 
-### 4.14 续期 K8s 证书
+### 4.15 续期 K8s 证书
 
 ```
 POST /api/k8s-certs/renew
@@ -628,7 +657,7 @@ POST /api/k8s-certs/renew
 
 **Response `data`:** `K8sCertInfo`
 
-### 4.15 删除 K8s 证书
+### 4.16 删除 K8s 证书
 
 ```
 POST /api/k8s-certs/delete
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerController.java
index 0e2a8129..15c0ee00 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerController.java
@@ -22,9 +22,11 @@ import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 @RestController
@@ -33,6 +35,13 @@ import 
org.springframework.web.bind.annotation.RestController;
 public class NameServerController {
 
     private final ClusterService clusterService;
+    private final NameServerConfigDiffService configDiffService;
+
+    @GetMapping("/config-diff")
+    public Result<NameServerConfigDiffVO> compareConfiguration(
+            @RequestParam(required = false) String clusterId) {
+        return Result.ok(configDiffService.compare(clusterId));
+    }
 
     @PostMapping("/create")
     public Result<NameServerVO> createNameServer(@Valid @RequestBody(required 
= false) CreateNameServerDTO command) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerControllerTest.java
index 037dc891..d0821658 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerControllerTest.java
@@ -31,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -48,6 +49,47 @@ class NameServerControllerTest {
     @MockBean
     private ClusterService clusterService;
 
+    @MockBean
+    private NameServerConfigDiffService configDiffService;
+
+    @Test
+    void compareConfigurationShouldReturnDriftResult() throws Exception {
+        NameServerConfigDiffVO result = NameServerConfigDiffVO.builder()
+                .cluster("cluster-1")
+                .complete(true)
+                .driftDetected(true)
+                .nodeCount(2)
+                .reachableNodeCount(2)
+                .comparedKeys(java.util.List.of("listenPort"))
+                .nodes(java.util.List.of())
+                .differences(java.util.List.of())
+                .build();
+        when(configDiffService.compare("cluster-1")).thenReturn(result);
+
+        mockMvc.perform(get("/api/nameservers/config-diff")
+                        .param("clusterId", "cluster-1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.cluster").value("cluster-1"))
+                .andExpect(jsonPath("$.data.driftDetected").value(true));
+
+        verify(configDiffService).compare("cluster-1");
+    }
+
+    @Test
+    void compareConfigurationShouldRejectMissingClusterId() throws Exception {
+        when(configDiffService.compare(null)).thenThrow(
+                new 
org.apache.rocketmq.studio.common.exception.BusinessException(
+                        400, "cluster is required"));
+
+        mockMvc.perform(get("/api/nameservers/config-diff"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("cluster is required"));
+
+        verify(configDiffService).compare(null);
+    }
+
     @Test
     void createNameServerShouldPassValidatedRequest() throws Exception {
         CreateNameServerDTO request = CreateNameServerDTO.builder()
diff --git a/web/src/App.tsx b/web/src/App.tsx
index db56997c..96e20230 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -39,6 +39,7 @@ const DashboardOpsPage = lazy(() => 
import('./pages/home/dashboard'));
 const AlertsPage = lazy(() => import('./pages/ops/alerts'));
 const SystemAlertsPage = lazy(() => import('./pages/ops/systemAlerts'));
 const AuditPage = lazy(() => import('./pages/ops/audit'));
+const NameServerConfigDriftPage = lazy(() => 
import('./pages/ops/nameServerConfigDrift'));
 const AiPage = lazy(() => import('./pages/ai'));
 const SettingsPage = lazy(() => import('./pages/settings'));
 const LlmSettingsPage = lazy(() => import('./pages/studio/LlmSettings'));
@@ -164,6 +165,7 @@ function App() {
             <Route path="ops/alerts" element={<AlertsPage />} />
             <Route path="ops/system-alerts" element={<SystemAlertsPage />} />
             <Route path="ops/audit" element={<AuditPage />} />
+            <Route path="ops/nameserver-config-drift" 
element={<NameServerConfigDriftPage />} />
             <Route path="ai" element={<AiPage />} />
             <Route path="settings" element={<SettingsPage />} />
             <Route path="studio/llm-settings" element={<LlmSettingsPage />} />
diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts
index c77a6645..1500b8d9 100644
--- a/web/src/api/cluster.test.ts
+++ b/web/src/api/cluster.test.ts
@@ -23,6 +23,7 @@ import {
   createNameServer,
   deleteK8sCert,
   deleteNameServer,
+  getNameServerConfigDiff,
   getCluster,
   listK8sCerts,
   renewK8sCert,
@@ -172,6 +173,36 @@ describe('K8s certificate API', () => {
     await expect(deleteNameServer(target)).resolves.toBeUndefined();
   });
 
+  it('loads NameServer configuration drift for the selected cluster', async () 
=> {
+    const result = {
+      cluster: 'cluster-1',
+      complete: true,
+      driftDetected: true,
+      nodeCount: 2,
+      reachableNodeCount: 2,
+      comparedKeys: ['listenPort'],
+      nodes: [
+        { address: 'ns-a:9876', reachable: true },
+        { address: 'ns-b:9876', reachable: true },
+      ],
+      differences: [
+        {
+          key: 'listenPort',
+          values: [
+            { address: 'ns-a:9876', configured: true, value: '9876' },
+            { address: 'ns-b:9876', configured: true, value: '19876' },
+          ],
+        },
+      ],
+    };
+    mock.onGet('/nameservers/config-diff', { params: { clusterId: 'cluster-1' 
} }).reply(200, {
+      code: 200,
+      data: result,
+    });
+
+    await 
expect(getNameServerConfigDiff('cluster-1')).resolves.toEqual(result);
+  });
+
   it('sends the proxy restart target', async () => {
     const target = { clusterId: 'cluster-1', addr: '127.0.0.1:8081' };
     mock.onPost('/proxies/restart').reply((config) => {
diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts
index cd952bba..0de78ce7 100644
--- a/web/src/api/cluster.ts
+++ b/web/src/api/cluster.ts
@@ -112,6 +112,28 @@ export interface K8sCertInfo {
   san: string[];
 }
 
+export interface NameServerConfigValue {
+  address: string;
+  configured: boolean;
+  value: string | null;
+}
+
+export interface NameServerConfigDifference {
+  key: string;
+  values: NameServerConfigValue[];
+}
+
+export interface NameServerConfigDiffResult {
+  cluster: string;
+  complete: boolean;
+  driftDetected: boolean;
+  nodeCount: number;
+  reachableNodeCount: number;
+  comparedKeys: string[];
+  nodes: Array<{ address: string; reachable: boolean }>;
+  differences: NameServerConfigDifference[];
+}
+
 // ─── Cluster ────────────────────────────────────────────────────
 export async function listClusters() {
   const res = await client.get<{ data: ClusterInfo[] }>('/clusters');
@@ -174,6 +196,13 @@ export async function updateNameServer(data: {
   await client.post('/nameservers/update', data);
 }
 
+export async function getNameServerConfigDiff(clusterId: string) {
+  const res = await client.get<{ data: NameServerConfigDiffResult 
}>('/nameservers/config-diff', {
+    params: { clusterId },
+  });
+  return res.data.data;
+}
+
 // ─── Proxy ──────────────────────────────────────────────────────
 export async function restartProxy(data: { clusterId: string; addr: string }) {
   await client.post('/proxies/restart', data);
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 66050329..eb18a06e 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -37,6 +37,7 @@ const translations: Record<string, Record<Lang, string>> = {
   'nav.alertRuleAssets': { zh: '告警规则模板', en: 'Alert Rule Templates' },
   'nav.audit': { zh: '审计日志', en: 'Audit Log' },
   'nav.grafanaDashboards': { zh: 'Grafana 看板', en: 'Grafana Dashboards' },
+  'nav.nameServerConfigDrift': { zh: 'NameServer 配置漂移', en: 'NameServer Config 
Drift' },
   'nav.ai': { zh: 'AI 交互', en: 'AI Chat' },
   'nav.settings': { zh: '设置', en: 'Settings' },
 
@@ -725,6 +726,40 @@ const translations: Record<string, Record<Lang, string>> = 
{
   },
   'ops.fetchFailed': { zh: '获取运维数据失败', en: 'Failed to fetch ops data' },
 
+  // ─── NameServer Config Drift ───
+  'nameServerDrift.title': { zh: 'NameServer 配置漂移', en: 'NameServer 
Configuration Drift' },
+  'nameServerDrift.cluster': { zh: '集群', en: 'Cluster' },
+  'nameServerDrift.selectCluster': { zh: '请选择集群', en: 'Select a cluster' },
+  'nameServerDrift.refresh': { zh: '重新检查', en: 'Check again' },
+  'nameServerDrift.export': { zh: '导出结果', en: 'Export result' },
+  'nameServerDrift.loadClustersFailed': {
+    zh: '集群列表加载失败',
+    en: 'Failed to load clusters',
+  },
+  'nameServerDrift.checkFailed': { zh: '配置漂移检查失败', en: 'Configuration check 
failed' },
+  'nameServerDrift.consistent': { zh: '配置一致', en: 'Configuration is 
consistent' },
+  'nameServerDrift.consistentDescription': {
+    zh: '已比较 {keys} 个安全配置项,所有可达节点配置一致。',
+    en: '{keys} safe configuration keys were compared and all reachable nodes 
are consistent.',
+  },
+  'nameServerDrift.driftDetected': { zh: '检测到配置漂移', en: 'Configuration drift 
detected' },
+  'nameServerDrift.driftDescription': {
+    zh: '发现 {count} 个配置项在 NameServer 节点间不一致。',
+    en: '{count} configuration keys differ across NameServer nodes.',
+  },
+  'nameServerDrift.incomplete': { zh: '检查结果不完整', en: 'Check result is 
incomplete' },
+  'nameServerDrift.incompleteDescription': {
+    zh: '仅成功读取 {reachable}/{total} 个 NameServer 节点。',
+    en: 'Only {reachable} of {total} NameServer nodes could be read.',
+  },
+  'nameServerDrift.nodes': { zh: 'NameServer 节点', en: 'NameServer Nodes' },
+  'nameServerDrift.reachable': { zh: '可达', en: 'Reachable' },
+  'nameServerDrift.unreachable': { zh: '不可达', en: 'Unreachable' },
+  'nameServerDrift.differences': { zh: '配置差异', en: 'Configuration Differences' 
},
+  'nameServerDrift.configKey': { zh: '配置项', en: 'Configuration Key' },
+  'nameServerDrift.notConfigured': { zh: '未配置', en: 'Not configured' },
+  'nameServerDrift.noClusters': { zh: '暂无可检查的集群', en: 'No clusters available' 
},
+
   // ─── Alert Management ───
   'alertMgmt.title': { zh: '告警规则管理', en: 'Alert Management' },
   'alertMgmt.alertName': { zh: '告警名称', en: 'Alert Name' },
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index ed09c449..168710b7 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -39,6 +39,7 @@ import {
   BellRinging,
   Notebook,
   Warning,
+  GitDiff,
 } from '@phosphor-icons/react';
 import { useLang } from '../i18n/LangContext';
 import { useTheme } from '../theme/useTheme';
@@ -134,6 +135,11 @@ const MainLayout = () => {
         { key: '/ops/alerts', icon: <BellRinging size={16} />, label: 
t('nav.alertRules') },
         { key: '/ops/system-alerts', icon: <BellRinging size={16} />, label: 
t('nav.alertEvents') },
         { key: '/ops/audit', icon: <Notebook size={16} />, label: 
t('nav.audit') },
+        {
+          key: '/ops/nameserver-config-drift',
+          icon: <GitDiff size={16} />,
+          label: t('nav.nameServerConfigDrift'),
+        },
         {
           key: '/ops/alert-rule-templates',
           icon: <Warning size={16} />,
@@ -166,6 +172,7 @@ const MainLayout = () => {
     '/ops/system-alerts': t('nav.alertEvents'),
     '/ops/alerts': t('nav.alertRules'),
     '/ops/audit': t('nav.audit'),
+    '/ops/nameserver-config-drift': t('nav.nameServerConfigDrift'),
     '/ops/alert-rule-templates': t('nav.alertRuleAssets'),
     '/ai': t('nav.ai'),
     '/settings': t('nav.settings'),
diff --git a/web/src/pages/ops/__tests__/NameServerConfigDriftPage.test.tsx 
b/web/src/pages/ops/__tests__/NameServerConfigDriftPage.test.tsx
new file mode 100644
index 00000000..f924694a
--- /dev/null
+++ b/web/src/pages/ops/__tests__/NameServerConfigDriftPage.test.tsx
@@ -0,0 +1,176 @@
+/*
+ * 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 type { ReactElement } from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { type ClusterInfo, type NameServerConfigDiffResult } from 
'../../../api/cluster';
+import { LangProvider } from '../../../i18n/LangContext';
+import { getNameServerConfigDiff, listClusters } from 
'../../../services/clusterService';
+import NameServerConfigDriftPage from '../nameServerConfigDrift';
+
+vi.mock('../../../services/clusterService', () => ({
+  getNameServerConfigDiff: vi.fn(),
+  listClusters: vi.fn(),
+}));
+
+const createObjectURL = vi.fn(() => 'blob:nameserver-config-drift');
+const revokeObjectURL = vi.fn();
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+  Object.defineProperties(URL, {
+    createObjectURL: { configurable: true, value: createObjectURL },
+    revokeObjectURL: { configurable: true, value: revokeObjectURL },
+  });
+});
+
+const cluster = {
+  id: 'cluster-a',
+  name: 'Production',
+} as ClusterInfo;
+
+const driftResult: NameServerConfigDiffResult = {
+  cluster: 'cluster-a',
+  complete: true,
+  driftDetected: true,
+  nodeCount: 2,
+  reachableNodeCount: 2,
+  comparedKeys: ['listenPort', 'serverWorkerThreads'],
+  nodes: [
+    { address: 'ns-a:9876', reachable: true },
+    { address: 'ns-b:9876', reachable: true },
+  ],
+  differences: [
+    {
+      key: 'listenPort',
+      values: [
+        { address: 'ns-a:9876', configured: true, value: '9876' },
+        { address: 'ns-b:9876', configured: true, value: '19876' },
+      ],
+    },
+  ],
+};
+
+const renderWithProviders = (ui: ReactElement) =>
+  render(
+    <App>
+      <LangProvider>{ui}</LangProvider>
+    </App>,
+  );
+
+describe('NameServerConfigDriftPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(listClusters).mockResolvedValue([cluster]);
+    vi.mocked(getNameServerConfigDiff).mockResolvedValue(driftResult);
+  });
+
+  it('checks the first cluster and renders node configuration differences', 
async () => {
+    renderWithProviders(<NameServerConfigDriftPage />);
+
+    await waitFor(() => {
+      expect(getNameServerConfigDiff).toHaveBeenCalledWith('cluster-a');
+    });
+    expect(await screen.findByText('检测到配置漂移')).toBeInTheDocument();
+    expect(screen.getByText('listenPort')).toBeInTheDocument();
+    expect(screen.getByText('19876')).toBeInTheDocument();
+    expect(screen.getByText('ns-a:9876 · 可达')).toBeInTheDocument();
+  });
+
+  it('reports a consistent result when no safe configuration differs', async 
() => {
+    vi.mocked(getNameServerConfigDiff).mockResolvedValue({
+      ...driftResult,
+      driftDetected: false,
+      differences: [],
+    });
+
+    renderWithProviders(<NameServerConfigDriftPage />);
+
+    expect(await screen.findByText('配置一致')).toBeInTheDocument();
+    expect(screen.queryByText('配置差异')).not.toBeInTheDocument();
+  });
+
+  it('preserves partial results when a NameServer is unreachable', async () => 
{
+    vi.mocked(getNameServerConfigDiff).mockResolvedValue({
+      ...driftResult,
+      complete: false,
+      reachableNodeCount: 1,
+      nodes: [
+        { address: 'ns-a:9876', reachable: true },
+        { address: 'ns-b:9876', reachable: false },
+      ],
+    });
+
+    renderWithProviders(<NameServerConfigDriftPage />);
+
+    expect(await screen.findByText('检查结果不完整')).toBeInTheDocument();
+    expect(screen.getByText('ns-b:9876 · 不可达')).toBeInTheDocument();
+  });
+
+  it('runs the check again from the refresh control', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<NameServerConfigDriftPage />);
+
+    await waitFor(() => {
+      expect(getNameServerConfigDiff).toHaveBeenCalledTimes(1);
+    });
+    await user.click(screen.getByRole('button', { name: '重新检查' }));
+
+    await waitFor(() => {
+      expect(getNameServerConfigDiff).toHaveBeenCalledTimes(2);
+    });
+  });
+
+  it('exports the current result as a cluster-scoped JSON file', async () => {
+    const user = userEvent.setup();
+    const click = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(() => {});
+
+    renderWithProviders(<NameServerConfigDriftPage />);
+    await user.click(await screen.findByRole('button', { name: '导出结果' }));
+
+    const downloadedLink = click.mock.instances[0] as HTMLAnchorElement;
+    expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
+    
expect(downloadedLink.download).toBe('nameserver-config-drift-cluster-a.json');
+    expect(downloadedLink.href).toBe('blob:nameserver-config-drift');
+    
expect(revokeObjectURL).toHaveBeenCalledWith('blob:nameserver-config-drift');
+    click.mockRestore();
+  });
+
+  it('renders an empty state without starting a check when no cluster exists', 
async () => {
+    vi.mocked(listClusters).mockResolvedValue([]);
+
+    renderWithProviders(<NameServerConfigDriftPage />);
+
+    expect(await screen.findByText('暂无可检查的集群')).toBeInTheDocument();
+    expect(getNameServerConfigDiff).not.toHaveBeenCalled();
+  });
+});
diff --git a/web/src/pages/ops/nameServerConfigDrift.tsx 
b/web/src/pages/ops/nameServerConfigDrift.tsx
new file mode 100644
index 00000000..2897e754
--- /dev/null
+++ b/web/src/pages/ops/nameServerConfigDrift.tsx
@@ -0,0 +1,236 @@
+/*
+ * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Alert, App, Button, Empty, Flex, Select, Table, Tag, Tooltip, 
Typography } from 'antd';
+import type { TableColumnsType } from 'antd';
+import { ArrowsClockwise, DownloadSimple } from '@phosphor-icons/react';
+import {
+  type ClusterInfo,
+  type NameServerConfigDifference,
+  type NameServerConfigDiffResult,
+} from '../../api/cluster';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import { getNameServerConfigDiff, listClusters } from 
'../../services/clusterService';
+
+const { Text, Title } = Typography;
+
+const NameServerConfigDriftPage = () => {
+  const { t } = useLang();
+  const { message } = App.useApp();
+  const requestSequence = useRef(0);
+  const [clusters, setClusters] = useState<ClusterInfo[]>([]);
+  const [selectedClusterId, setSelectedClusterId] = useState<string>();
+  const [clustersLoading, setClustersLoading] = useState(true);
+  const [checking, setChecking] = useState(false);
+  const [result, setResult] = useState<NameServerConfigDiffResult>();
+
+  const runCheck = useCallback(
+    async (clusterId: string) => {
+      const sequence = ++requestSequence.current;
+      setChecking(true);
+      try {
+        const nextResult = await getNameServerConfigDiff(clusterId);
+        if (sequence === requestSequence.current) setResult(nextResult);
+      } catch {
+        if (sequence === requestSequence.current) {
+          setResult(undefined);
+          message.error(t('nameServerDrift.checkFailed'));
+        }
+      } finally {
+        if (sequence === requestSequence.current) setChecking(false);
+      }
+    },
+    [message, t],
+  );
+
+  useEffect(() => {
+    let cancelled = false;
+    void listClusters()
+      .then((items) => {
+        if (cancelled) return;
+        setClusters(items);
+        const firstClusterId = items[0]?.id;
+        setSelectedClusterId(firstClusterId);
+        if (firstClusterId) void runCheck(firstClusterId);
+      })
+      .catch(() => {
+        if (!cancelled) message.error(t('nameServerDrift.loadClustersFailed'));
+      })
+      .finally(() => {
+        if (!cancelled) setClustersLoading(false);
+      });
+    return () => {
+      cancelled = true;
+      requestSequence.current += 1;
+    };
+  }, [message, runCheck, t]);
+
+  const selectCluster = (clusterId: string) => {
+    setSelectedClusterId(clusterId);
+    setResult(undefined);
+    void runCheck(clusterId);
+  };
+
+  const columns = useMemo<TableColumnsType<NameServerConfigDifference>>(() => {
+    const nodeAddresses = result?.nodes.map((node) => node.address) ?? [];
+    return [
+      {
+        title: t('nameServerDrift.configKey'),
+        dataIndex: 'key',
+        key: 'key',
+        fixed: 'left',
+        width: 220,
+        render: (key: string) => <Text code>{key}</Text>,
+      },
+      ...nodeAddresses.map((address) => ({
+        title: address,
+        key: address,
+        width: 220,
+        render: (_: unknown, difference: NameServerConfigDifference) => {
+          const config = difference.values.find((value) => value.address === 
address);
+          if (!config?.configured)
+            return <Text 
type="secondary">{t('nameServerDrift.notConfigured')}</Text>;
+          return <Text>{config.value}</Text>;
+        },
+      })),
+    ];
+  }, [result, t]);
+
+  const exportResult = () => {
+    if (!result) return;
+    const blob = new Blob([JSON.stringify(result, null, 2)], { type: 
'application/json' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = 
`nameserver-config-drift-${result.cluster.replace(/[^a-zA-Z0-9._-]/g, 
'_')}.json`;
+    link.click();
+    URL.revokeObjectURL(url);
+  };
+
+  const statusAlert = result ? (
+    !result.complete ? (
+      <Alert
+        showIcon
+        type="warning"
+        message={t('nameServerDrift.incomplete')}
+        description={t('nameServerDrift.incompleteDescription', {
+          reachable: result.reachableNodeCount,
+          total: result.nodeCount,
+        })}
+      />
+    ) : result.driftDetected ? (
+      <Alert
+        showIcon
+        type="warning"
+        message={t('nameServerDrift.driftDetected')}
+        description={t('nameServerDrift.driftDescription', { count: 
result.differences.length })}
+      />
+    ) : (
+      <Alert
+        showIcon
+        type="success"
+        message={t('nameServerDrift.consistent')}
+        description={t('nameServerDrift.consistentDescription', {
+          keys: result.comparedKeys.length,
+        })}
+      />
+    )
+  ) : null;
+
+  return (
+    <div style={{ padding: 24 }}>
+      <PageHeader title={t('nameServerDrift.title')} />
+
+      <Flex wrap gap={8} align="center" style={{ marginBottom: 20 }}>
+        <Select
+          aria-label={t('nameServerDrift.cluster')}
+          loading={clustersLoading}
+          value={selectedClusterId}
+          onChange={selectCluster}
+          placeholder={t('nameServerDrift.selectCluster')}
+          options={clusters.map((cluster) => ({
+            label: cluster.name || cluster.id,
+            value: cluster.id,
+          }))}
+          style={{ width: 'min(100%, 360px)' }}
+        />
+        <Tooltip title={t('nameServerDrift.refresh')}>
+          <Button
+            aria-label={t('nameServerDrift.refresh')}
+            icon={<ArrowsClockwise size={16} />}
+            loading={checking}
+            disabled={!selectedClusterId}
+            onClick={() => selectedClusterId && void 
runCheck(selectedClusterId)}
+          />
+        </Tooltip>
+        <Tooltip title={t('nameServerDrift.export')}>
+          <Button
+            aria-label={t('nameServerDrift.export')}
+            icon={<DownloadSimple size={16} />}
+            disabled={!result}
+            onClick={exportResult}
+          />
+        </Tooltip>
+      </Flex>
+
+      {!clustersLoading && clusters.length === 0 && (
+        <Empty description={t('nameServerDrift.noClusters')} />
+      )}
+
+      {clusters.length > 0 && (
+        <Flex vertical gap={24}>
+          {statusAlert}
+
+          {result && (
+            <section>
+              <Title level={5}>{t('nameServerDrift.nodes')}</Title>
+              <Flex wrap gap={8}>
+                {result.nodes.map((node) => (
+                  <Tag key={node.address} color={node.reachable ? 'success' : 
'error'}>
+                    {node.address} ·{' '}
+                    {node.reachable
+                      ? t('nameServerDrift.reachable')
+                      : t('nameServerDrift.unreachable')}
+                  </Tag>
+                ))}
+              </Flex>
+            </section>
+          )}
+
+          {result && result.differences.length > 0 && (
+            <section>
+              <Title level={5}>{t('nameServerDrift.differences')}</Title>
+              <Table
+                rowKey="key"
+                size="small"
+                loading={checking}
+                columns={columns}
+                dataSource={result.differences}
+                pagination={false}
+                scroll={{ x: 'max-content' }}
+              />
+            </section>
+          )}
+        </Flex>
+      )}
+    </div>
+  );
+};
+
+export default NameServerConfigDriftPage;
diff --git a/web/src/services/clusterService.test.ts 
b/web/src/services/clusterService.test.ts
index 1d9e0ed6..8752e3e2 100644
--- a/web/src/services/clusterService.test.ts
+++ b/web/src/services/clusterService.test.ts
@@ -26,6 +26,7 @@ import {
   createK8sCert,
   deleteK8sCert,
   getCluster,
+  getNameServerConfigDiff,
   listClusters,
   listK8sCerts,
   updateClusterConfig,
@@ -71,6 +72,23 @@ describe('clusterService mock clusters', () => {
     expect(detail.tpsHistory).not.toBe(listed.tpsHistory);
   });
 
+  it('returns a complete mock NameServer drift result for the selected 
cluster', async () => {
+    const result = await getNameServerConfigDiff('cluster-prod');
+
+    expect(result.cluster).toBe('cluster-prod');
+    expect(result.nodeCount).toBeGreaterThan(1);
+    expect(result.reachableNodeCount).toBe(result.nodeCount);
+    expect(result.driftDetected).toBe(true);
+    expect(result.differences).toEqual([
+      expect.objectContaining({
+        key: 'serverWorkerThreads',
+        values: expect.arrayContaining([
+          expect.objectContaining({ address: expect.any(String), configured: 
true }),
+        ]),
+      }),
+    ]);
+  });
+
   it('persists partial mock config updates without copying id into config', 
async () => {
     const before = await getCluster('cluster-prod');
     const originalConfig = { ...before.config };
diff --git a/web/src/services/clusterService.ts 
b/web/src/services/clusterService.ts
index 6d83bcdc..b15d9760 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -6,6 +6,7 @@ import type {
   ClusterInfo,
   ClusterProbeResult,
   K8sCertInfo,
+  NameServerConfigDiffResult,
 } from '../api/cluster';
 import clusters, { mockK8sCerts } from '../mock/clusters';
 
@@ -67,6 +68,42 @@ export async function getCluster(id: string): 
Promise<ClusterInfo> {
   return clusterApi.getCluster(id);
 }
 
+export async function getNameServerConfigDiff(
+  clusterId: string,
+): Promise<NameServerConfigDiffResult> {
+  if (!isMockMode()) return clusterApi.getNameServerConfigDiff(clusterId);
+
+  const cluster = getMockCluster(clusterId);
+  const nodes = cluster.nameServers.map((nameServer) => ({
+    address: nameServer.addr,
+    reachable: nameServer.status !== 'offline',
+  }));
+  const reachableAddresses = nodes.filter((node) => node.reachable).map((node) 
=> node.address);
+  const driftDetected = cluster.id === 'cluster-prod' && 
reachableAddresses.length > 1;
+
+  return {
+    cluster: cluster.id,
+    complete: reachableAddresses.length === nodes.length,
+    driftDetected,
+    nodeCount: nodes.length,
+    reachableNodeCount: reachableAddresses.length,
+    comparedKeys: ['listenPort', 'serverWorkerThreads', 
'clientRequestThreadPoolNums'],
+    nodes,
+    differences: driftDetected
+      ? [
+          {
+            key: 'serverWorkerThreads',
+            values: reachableAddresses.map((address, index) => ({
+              address,
+              configured: true,
+              value: index === 0 ? '8' : '12',
+            })),
+          },
+        ]
+      : [],
+  };
+}
+
 export async function listK8sCerts(): Promise<K8sCertInfo[]> {
   if (isMockMode()) return mockCertStore.map((cert) => ({ ...cert, san: 
[...cert.san] }));
   return clusterApi.listK8sCerts();

Reply via email to