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();