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 469e4764 fix: replace synthetic cluster metrics with real auto-refresh 
polling (#681)
469e4764 is described below

commit 469e4764921dec900aa4224854df66c21b05945c
Author: Rui <[email protected]>
AuthorDate: Fri Jul 31 16:22:07 2026 +0800

    fix: replace synthetic cluster metrics with real auto-refresh polling (#681)
---
 .../pages/cluster/__tests__/ClusterPage.test.tsx   | 387 ++++++++++++++++++++-
 web/src/pages/cluster/index.tsx                    | 315 ++++++++++-------
 web/src/services/clusterService.test.ts            |  26 +-
 web/src/services/clusterService.ts                 |  10 +-
 4 files changed, 598 insertions(+), 140 deletions(-)

diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index ad3577bd..8ead1bdb 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -15,12 +15,24 @@
  * limitations under the License.
  */
 
-import { App } from 'antd';
-import { render, screen, within } from '@testing-library/react';
+import { App, message, Modal } from 'antd';
+import { act, fireEvent, render, screen, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import type React from 'react';
-import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
+import type { ClusterInfo } from '../../../api/cluster';
 import { LangProvider } from '../../../i18n/LangContext';
+
+const clusterServiceMocks = vi.hoisted(() => ({
+  createNameServer: vi.fn(),
+  listClusters: vi.fn(),
+  restartProxy: vi.fn(),
+  updateClusterConfig: vi.fn(),
+  updateNameServer: vi.fn(),
+}));
+
+vi.mock('../../../services/clusterService', () => clusterServiceMocks);
+
 import ClusterPage from '../index';
 
 beforeAll(() => {
@@ -46,13 +58,98 @@ const renderWithProviders = (ui: React.ReactElement) =>
     </App>,
   );
 
+const buildCluster = ({
+  tpsIn = 12480,
+  tpsOut = 34560,
+  connections = 1842,
+}: {
+  tpsIn?: number;
+  tpsOut?: number;
+  connections?: number;
+} = {}): ClusterInfo => ({
+  id: 'cluster-prod',
+  name: 'rocketmq-prod',
+  nsClusterName: 'ns-prod',
+  type: 'V5_PROXY_CLUSTER',
+  endpoint: '10.101.2.1:9876',
+  status: 'healthy',
+  version: '5.2.0',
+  brokers: [
+    {
+      name: 'rocketmq-prod-0',
+      addr: '10.101.2.11:10911',
+      version: '5.2.0',
+      status: 'running',
+      diskUsage: 62,
+      tpsIn,
+      tpsOut,
+    },
+  ],
+  proxies: [
+    {
+      addr: '10.101.2.21:8081',
+      status: 'healthy',
+      connections,
+      grpcPort: 8081,
+      remotingPort: 8080,
+    },
+  ],
+  nameServers: [{ addr: '10.101.2.1:9876', status: 'healthy' }],
+  config: {
+    flushDiskType: 'SYNC_FLUSH',
+    autoCreateTopicEnable: false,
+    autoCreateSubscriptionGroup: false,
+    maxMessageSize: 4 * 1024 * 1024,
+    msgTraceTopicName: 'RMQ_SYS_TRACE_TOPIC',
+    fileReservedTime: 72,
+    writeQueueNums: 8,
+    readQueueNums: 8,
+    brokerPermission: 6,
+    deleteWhen: '04',
+  },
+  topicCount: 10,
+  groupCount: 5,
+  tpsHistory: [tpsIn],
+});
+
+const deferred = <T,>() => {
+  let resolve!: (value: T | PromiseLike<T>) => void;
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((promiseResolve, promiseReject) => {
+    resolve = promiseResolve;
+    reject = promiseReject;
+  });
+  return { promise, reject, resolve };
+};
+
+const flushPromises = async () => {
+  await act(async () => {
+    await Promise.resolve();
+    await Promise.resolve();
+  });
+};
+
 describe('Cluster page', () => {
+  beforeEach(() => {
+    
clusterServiceMocks.createNameServer.mockReset().mockResolvedValue(undefined);
+    
clusterServiceMocks.listClusters.mockReset().mockResolvedValue([buildCluster()]);
+    clusterServiceMocks.restartProxy.mockReset().mockResolvedValue(undefined);
+    
clusterServiceMocks.updateClusterConfig.mockReset().mockResolvedValue(undefined);
+    
clusterServiceMocks.updateNameServer.mockReset().mockResolvedValue(undefined);
+  });
+
+  afterEach(() => {
+    Modal.destroyAll();
+    vi.useRealTimers();
+    vi.restoreAllMocks();
+  });
+
   it('opens proxy detail dialog from the proxy table', async () => {
     const user = userEvent.setup();
     renderWithProviders(<ClusterPage />);
 
     await user.click(screen.getByRole('tab', { name: /Proxy 管理/ }));
-    const proxyRow = screen.getByRole('row', { name: /10\.101\.2\.21:8081/ });
+    const proxyRow = await screen.findByRole('row', { name: 
/10\.101\.2\.21:8081/ });
     await user.click(within(proxyRow).getByRole('button', { name: /详情/ }));
 
     const dialog = await screen.findByRole('dialog', { name: /Proxy 详情 - 
10\.101\.2\.21:8081/ });
@@ -63,4 +160,286 @@ describe('Cluster page', () => {
     expect(within(dialog).getByText('8081')).toBeInTheDocument();
     expect(within(dialog).getByText('8080')).toBeInTheDocument();
   });
+
+  it('polls the API after two seconds and renders only returned metrics', 
async () => {
+    vi.useFakeTimers();
+    const randomSpy = vi.spyOn(Math, 'random');
+    clusterServiceMocks.listClusters
+      .mockResolvedValueOnce([buildCluster({ tpsIn: 101, tpsOut: 201, 
connections: 501 })])
+      .mockResolvedValueOnce([buildCluster({ tpsIn: 102, tpsOut: 202, 
connections: 502 })]);
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    randomSpy.mockClear();
+
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(1);
+    expect(screen.getByRole('row', { name: /10\.101\.2\.11:10911/ 
})).toHaveTextContent('101');
+    fireEvent.click(screen.getByRole('tab', { name: /Proxy 管理/ }));
+    const initialProxyRow = screen.getByRole('row', { name: 
/10\.101\.2\.21:8081/ });
+    expect(initialProxyRow).toHaveTextContent('501');
+    fireEvent.click(within(initialProxyRow).getByRole('button', { name: /详情/ 
}));
+    const proxyDialog = screen.getByRole('dialog', {
+      name: /Proxy 详情 - 10\.101\.2\.21:8081/,
+    });
+    randomSpy.mockClear();
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(1999);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(1);
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(1);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+    expect(within(proxyDialog).getByText('502')).toBeInTheDocument();
+    expect(randomSpy).not.toHaveBeenCalled();
+    const proxyTabPanel = screen.getByRole('tabpanel', { name: /Proxy 管理/ });
+    const proxyTable = within(proxyTabPanel).getByRole('table');
+    expect(within(proxyTable).getByRole('row', { name: /10\.101\.2\.21:8081/ 
})).toHaveTextContent(
+      '502',
+    );
+    fireEvent.click(screen.getByRole('tab', { name: /Broker 管理/ }));
+    expect(screen.getByRole('row', { name: /10\.101\.2\.11:10911/ 
})).toHaveTextContent('102');
+  });
+
+  it('waits two seconds after a slow request completes before polling again', 
async () => {
+    vi.useFakeTimers();
+    const slowRefresh = deferred<ClusterInfo[]>();
+    clusterServiceMocks.listClusters
+      .mockResolvedValueOnce([buildCluster()])
+      .mockReturnValueOnce(slowRefresh.promise)
+      .mockResolvedValue([buildCluster({ tpsIn: 103 })]);
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(2000);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(10000);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+
+    await act(async () => {
+      slowRefresh.resolve([buildCluster({ tpsIn: 102 })]);
+      await slowRefresh.promise;
+    });
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(1999);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(1);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(3);
+  });
+
+  it('stops polling when disabled and refreshes immediately when re-enabled', 
async () => {
+    vi.useFakeTimers();
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(1);
+
+    const autoRefreshSwitch = screen.getByRole('switch');
+    fireEvent.click(autoRefreshSwitch);
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(5000);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(1);
+
+    fireEvent.click(autoRefreshSwitch);
+    await flushPromises();
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+  });
+
+  it('does not schedule another poll when disabled during an in-flight 
refresh', async () => {
+    vi.useFakeTimers();
+    const slowRefresh = deferred<ClusterInfo[]>();
+    clusterServiceMocks.listClusters
+      .mockResolvedValueOnce([buildCluster()])
+      .mockReturnValueOnce(slowRefresh.promise);
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(2000);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+
+    fireEvent.click(screen.getByRole('switch'));
+    await act(async () => {
+      slowRefresh.resolve([buildCluster({ tpsIn: 104 })]);
+      await slowRefresh.promise;
+      await vi.advanceTimersByTimeAsync(10000);
+    });
+
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+  });
+
+  it('queues one foreground follow-up when a refresh is already in flight', 
async () => {
+    vi.useFakeTimers();
+    const initialRequest = deferred<ClusterInfo[]>();
+    clusterServiceMocks.listClusters
+      .mockReturnValueOnce(initialRequest.promise)
+      .mockResolvedValueOnce([buildCluster({ tpsIn: 202 })]);
+
+    renderWithProviders(<ClusterPage />);
+    fireEvent.click(screen.getByRole('button', { name: '刷新' }));
+    fireEvent.click(screen.getByRole('button', { name: '刷新' }));
+
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(1);
+    await act(async () => {
+      initialRequest.resolve([buildCluster({ tpsIn: 201 })]);
+      await initialRequest.promise;
+      await Promise.resolve();
+    });
+
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+    expect(screen.getByRole('row', { name: /10\.101\.2\.11:10911/ 
})).toHaveTextContent('202');
+  });
+
+  it('queues an operation refresh behind an in-flight background request', 
async () => {
+    vi.useFakeTimers();
+    const successSpy = vi.spyOn(message, 
'success').mockImplementation(vi.fn());
+    const backgroundRequest = deferred<ClusterInfo[]>();
+    const operationRequest = deferred<void>();
+    let backgroundSettled = false;
+    void backgroundRequest.promise.then(() => {
+      backgroundSettled = true;
+    });
+    
clusterServiceMocks.restartProxy.mockReturnValueOnce(operationRequest.promise);
+    clusterServiceMocks.listClusters
+      .mockResolvedValueOnce([buildCluster({ connections: 601 })])
+      .mockReturnValueOnce(backgroundRequest.promise)
+      .mockResolvedValueOnce([buildCluster({ connections: 603 })]);
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    fireEvent.click(screen.getByRole('tab', { name: /Proxy 管理/ }));
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(2000);
+    });
+
+    const proxyRow = screen.getByRole('row', { name: /10\.101\.2\.21:8081/ });
+    fireEvent.click(within(proxyRow).getByRole('button', { name: /重启/ }));
+    await flushPromises();
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(0);
+    });
+    const dialog = screen.getAllByText('确认重启')[0].closest('.ant-modal');
+    expect(dialog).not.toBeNull();
+    fireEvent.click(within(dialog as HTMLElement).getByRole('button', { name: 
/确\s*认/ }));
+    await flushPromises();
+
+    expect(clusterServiceMocks.restartProxy).toHaveBeenCalledWith({
+      clusterId: 'cluster-prod',
+      addr: '10.101.2.21:8081',
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+
+    await act(async () => {
+      operationRequest.resolve();
+      await operationRequest.promise;
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+    expect(backgroundSettled).toBe(false);
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(2);
+    expect(successSpy).not.toHaveBeenCalled();
+
+    await act(async () => {
+      backgroundRequest.resolve([buildCluster({ connections: 602 })]);
+      await backgroundRequest.promise;
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+    await flushPromises();
+
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(3);
+    const proxyTabPanel = screen.getByRole('tabpanel', { name: /Proxy 管理/ });
+    const proxyTable = within(proxyTabPanel).getByRole('table');
+    expect(within(proxyTable).getByRole('row', { name: /10\.101\.2\.21:8081/ 
})).toHaveTextContent(
+      '603',
+    );
+    expect(successSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('keeps the last snapshot and silently retries after a background 
failure', async () => {
+    vi.useFakeTimers();
+    const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    clusterServiceMocks.listClusters
+      .mockResolvedValueOnce([buildCluster({ tpsIn: 301 })])
+      .mockRejectedValueOnce(new Error('temporary failure'))
+      .mockResolvedValueOnce([buildCluster({ tpsIn: 302 })]);
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(2000);
+    });
+
+    expect(screen.getByRole('row', { name: /10\.101\.2\.11:10911/ 
})).toHaveTextContent('301');
+    expect(screen.getByText('刷新失败')).toBeInTheDocument();
+    expect(errorSpy).not.toHaveBeenCalled();
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(2000);
+    });
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(3);
+    expect(screen.getByRole('row', { name: /10\.101\.2\.11:10911/ 
})).toHaveTextContent('302');
+    expect(screen.queryByText('刷新失败')).not.toBeInTheDocument();
+  });
+
+  it('reports initial and manual failures', async () => {
+    vi.useFakeTimers();
+    const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    clusterServiceMocks.listClusters.mockRejectedValue(new Error('failure'));
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    expect(errorSpy).toHaveBeenCalledTimes(1);
+
+    fireEvent.click(screen.getByRole('switch'));
+    fireEvent.click(screen.getByRole('button', { name: '刷新' }));
+    await flushPromises();
+    expect(errorSpy).toHaveBeenCalledTimes(2);
+  });
+
+  it('clears tables and metric snapshots when the API returns an empty list', 
async () => {
+    vi.useFakeTimers();
+    clusterServiceMocks.listClusters
+      .mockResolvedValueOnce([buildCluster({ tpsIn: 401 })])
+      .mockResolvedValueOnce([]);
+
+    renderWithProviders(<ClusterPage />);
+    await flushPromises();
+    expect(screen.getByRole('row', { name: /10\.101\.2\.11:10911/ 
})).toHaveTextContent('401');
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(2000);
+    });
+    expect(screen.queryByRole('row', { name: /10\.101\.2\.11:10911/ 
})).not.toBeInTheDocument();
+    expect(screen.getByText(/共 0 RocketMQ 集群/)).toBeInTheDocument();
+  });
+
+  it('ignores late responses and does not schedule polling after unmount', 
async () => {
+    vi.useFakeTimers();
+    const initialRequest = deferred<ClusterInfo[]>();
+    clusterServiceMocks.listClusters.mockReturnValue(initialRequest.promise);
+    const view = renderWithProviders(<ClusterPage />);
+
+    view.unmount();
+    await act(async () => {
+      initialRequest.resolve([buildCluster()]);
+      await initialRequest.promise;
+      await vi.advanceTimersByTimeAsync(5000);
+    });
+
+    expect(clusterServiceMocks.listClusters).toHaveBeenCalledTimes(1);
+  });
 });
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index e86006ae..d2d53153 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState, useEffect } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import {
   Table,
   Tabs,
@@ -64,27 +64,9 @@ import {
 
 const { Text } = Typography;
 
-const buildBrokerTpsMap = (
-  clusters: ClusterInfo[],
-): Record<string, { tpsIn: number; tpsOut: number }> => {
-  const result: Record<string, { tpsIn: number; tpsOut: number }> = {};
-  clusters.forEach((cluster) =>
-    cluster.brokers.forEach((broker) => {
-      result[broker.addr] = { tpsIn: broker.tpsIn, tpsOut: broker.tpsOut };
-    }),
-  );
-  return result;
-};
+const REFRESH_INTERVAL_MS = 2000;
 
-const buildProxyConnMap = (clusters: ClusterInfo[]): Record<string, number> => 
{
-  const result: Record<string, number> = {};
-  clusters.forEach((cluster) =>
-    cluster.proxies.forEach((proxy) => {
-      result[proxy.addr] = proxy.connections;
-    }),
-  );
-  return result;
-};
+type RefreshSource = 'initial' | 'manual' | 'operation' | 'background';
 
 type ProxyDetail = ProxyInfo & { clusterId: string; clusterName: string; 
nsClusterName: string };
 
@@ -107,91 +89,147 @@ const ClusterPage = () => {
   const [nsForm] = Form.useForm();
   const [configForm] = Form.useForm();
 
-  // ─── Auto-refresh TPS / connections every 2s ──────────────────────────────
+  // ─── Cluster refresh coordinator ──────────────────────────────────────────
   const [autoRefresh, setAutoRefresh] = useState(true);
-  const [brokerTpsMap, setBrokerTpsMap] = useState<
-    Record<string, { tpsIn: number; tpsOut: number }>
-  >({});
-  const [proxyConnMap, setProxyConnMap] = useState<Record<string, number>>({});
-
-  const applyClusters = (nextClusters: ClusterInfo[]) => {
-    setClusters(nextClusters);
-    setBrokerTpsMap(buildBrokerTpsMap(nextClusters));
-    setProxyConnMap(buildProxyConnMap(nextClusters));
-  };
+  const [refreshFailed, setRefreshFailed] = useState(false);
+  const mountedRef = useRef(false);
+  const autoRefreshRef = useRef(true);
+  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const inFlightRefreshRef = useRef<Promise<void> | null>(null);
+  const queuedForegroundRef = useRef<RefreshSource | null>(null);
+  const queuedBackgroundRef = useRef(false);
+  const requestRefreshRef = useRef<(source: RefreshSource) => 
Promise<void>>(() =>
+    Promise.resolve(),
+  );
+  const tRef = useRef(t);
 
-  const refreshClusters = async (showLoading = false) => {
-    if (showLoading) setLoading(true);
-    try {
-      applyClusters(await listClusters());
-    } catch {
-      message.error(t('common.fetchDataFailed'));
-    } finally {
-      if (showLoading) setLoading(false);
+  const clearRefreshTimer = useCallback(() => {
+    if (refreshTimerRef.current !== null) {
+      clearTimeout(refreshTimerRef.current);
+      refreshTimerRef.current = null;
     }
-  };
+  }, []);
 
-  useEffect(() => {
-    let cancelled = false;
-
-    const fetchClusters = async () => {
-      try {
-        const nextClusters = await listClusters();
-        if (!cancelled) {
-          setClusters(nextClusters);
-          setBrokerTpsMap(buildBrokerTpsMap(nextClusters));
-          setProxyConnMap(buildProxyConnMap(nextClusters));
+  const requestRefresh = useCallback(
+    (source: RefreshSource): Promise<void> => {
+      clearRefreshTimer();
+      if (!mountedRef.current) return Promise.resolve();
+
+      if (source !== 'background') setLoading(true);
+
+      if (inFlightRefreshRef.current) {
+        if (source === 'background') {
+          if (autoRefreshRef.current) queuedBackgroundRef.current = true;
+        } else if (source !== 'initial' && !queuedForegroundRef.current) {
+          queuedForegroundRef.current = source;
         }
-      } catch {
-        if (!cancelled) message.error(t('common.fetchDataFailed'));
-      } finally {
-        if (!cancelled) setLoading(false);
+        return inFlightRefreshRef.current;
       }
-    };
 
-    void fetchClusters();
-    return () => {
-      cancelled = true;
-    };
+      const runRefreshes = async () => {
+        let currentSource: RefreshSource | null = source;
+
+        while (currentSource && mountedRef.current) {
+          try {
+            const nextClusters = await listClusters();
+            if (!mountedRef.current) return;
+            setClusters(nextClusters);
+            setSelectedProxy((current) => {
+              if (!current) return null;
+              const cluster = nextClusters.find((item) => item.id === 
current.clusterId);
+              const proxy = cluster?.proxies.find((item) => item.addr === 
current.addr);
+              if (!cluster || !proxy) return null;
+              return {
+                ...proxy,
+                clusterId: cluster.id,
+                clusterName: cluster.name,
+                nsClusterName: cluster.nsClusterName,
+              };
+            });
+            setRefreshFailed(false);
+          } catch {
+            if (!mountedRef.current) return;
+            setRefreshFailed(true);
+            if (currentSource !== 'background') {
+              message.error(tRef.current('common.fetchDataFailed'));
+            }
+          }
+
+          if (!mountedRef.current) return;
+          if (queuedForegroundRef.current) {
+            currentSource = queuedForegroundRef.current;
+            queuedForegroundRef.current = null;
+            queuedBackgroundRef.current = false;
+          } else if (queuedBackgroundRef.current && autoRefreshRef.current) {
+            currentSource = 'background';
+            queuedBackgroundRef.current = false;
+          } else {
+            queuedBackgroundRef.current = false;
+            currentSource = null;
+          }
+        }
+      };
+
+      const refreshCycle = runRefreshes().finally(() => {
+        inFlightRefreshRef.current = null;
+        if (!mountedRef.current) return;
+
+        if (queuedForegroundRef.current) {
+          const queuedSource = queuedForegroundRef.current;
+          queuedForegroundRef.current = null;
+          queuedBackgroundRef.current = false;
+          return requestRefreshRef.current(queuedSource);
+        }
+        if (queuedBackgroundRef.current && autoRefreshRef.current) {
+          queuedBackgroundRef.current = false;
+          return requestRefreshRef.current('background');
+        }
+
+        queuedBackgroundRef.current = false;
+        setLoading(false);
+        if (autoRefreshRef.current) {
+          refreshTimerRef.current = setTimeout(() => {
+            refreshTimerRef.current = null;
+            void requestRefreshRef.current('background');
+          }, REFRESH_INTERVAL_MS);
+        }
+      });
+      inFlightRefreshRef.current = refreshCycle;
+      return refreshCycle;
+    },
+    [clearRefreshTimer],
+  );
+
+  useEffect(() => {
+    tRef.current = t;
   }, [t]);
 
   useEffect(() => {
-    if (!autoRefresh) return;
-
-    const timer = setInterval(() => {
-      setBrokerTpsMap((prev) => {
-        const next = { ...prev };
-        clusters.forEach((c) => {
-          c.brokers.forEach((b) => {
-            const cur = next[b.addr] ?? { tpsIn: b.tpsIn, tpsOut: b.tpsOut };
-            const fluctuate = (base: number, v: number) =>
-              base === 0 ? 0 : Math.max(0, Math.round(v + (Math.random() - 
0.5) * base * 0.12));
-            next[b.addr] = {
-              tpsIn: fluctuate(b.tpsIn, cur.tpsIn),
-              tpsOut: fluctuate(b.tpsOut, cur.tpsOut),
-            };
-          });
-        });
-        return next;
-      });
+    requestRefreshRef.current = requestRefresh;
+  }, [requestRefresh]);
 
-      setProxyConnMap((prev) => {
-        const next = { ...prev };
-        clusters.forEach((c) => {
-          c.proxies.forEach((p) => {
-            const cur = prev[p.addr] ?? p.connections;
-            next[p.addr] = Math.max(
-              0,
-              Math.round(cur + (Math.random() - 0.5) * p.connections * 0.08),
-            );
-          });
-        });
-        return next;
-      });
-    }, 2000);
+  useEffect(() => {
+    mountedRef.current = true;
+    autoRefreshRef.current = true;
+    void requestRefreshRef.current('initial');
+    return () => {
+      mountedRef.current = false;
+      clearRefreshTimer();
+      queuedForegroundRef.current = null;
+      queuedBackgroundRef.current = false;
+    };
+  }, [clearRefreshTimer]);
 
-    return () => clearInterval(timer);
-  }, [autoRefresh, clusters]);
+  const handleAutoRefreshChange = (checked: boolean) => {
+    autoRefreshRef.current = checked;
+    setAutoRefresh(checked);
+    clearRefreshTimer();
+    if (!checked) {
+      queuedBackgroundRef.current = false;
+      return;
+    }
+    void requestRefresh('background');
+  };
 
   // Broker config handler
   const handleConfigOpen = (cluster: ClusterInfo) => {
@@ -263,17 +301,12 @@ const ClusterPage = () => {
             !brokerNsClusterFilter || c.nsClusterName === 
brokerNsClusterFilter;
           return matchSearch && matchNsCluster;
         })
-        .map((b) => {
-          const tpsOverride = brokerTpsMap[b.addr];
-          return {
-            ...b,
-            tpsIn: tpsOverride?.tpsIn ?? b.tpsIn,
-            tpsOut: tpsOverride?.tpsOut ?? b.tpsOut,
-            clusterName: c.name,
-            nsClusterName: c.nsClusterName,
-            cluster: c,
-          };
-        }),
+        .map((b) => ({
+          ...b,
+          clusterName: c.name,
+          nsClusterName: c.nsClusterName,
+          cluster: c,
+        })),
     );
 
     const brokerColumns: ColumnsType<BrokerWithCluster> = [
@@ -455,14 +488,7 @@ const ClusterPage = () => {
                   id: selectedCluster.id,
                   ...nextConfig,
                 });
-                setClusters((prev) =>
-                  prev.map((cluster) =>
-                    cluster.id === selectedCluster.id
-                      ? { ...cluster, config: nextConfig }
-                      : cluster,
-                  ),
-                );
-                setSelectedCluster((prev) => (prev ? { ...prev, config: 
nextConfig } : prev));
+                await requestRefresh('operation');
                 message.success(t('cluster.configUpdated'));
                 setConfigModalOpen(false);
               });
@@ -700,7 +726,6 @@ const ClusterPage = () => {
           })
           .map((p) => ({
             ...p,
-            connections: proxyConnMap[p.addr] ?? p.connections,
             clusterId: c.id,
             clusterName: c.name,
             nsClusterName: c.nsClusterName,
@@ -800,7 +825,7 @@ const ClusterPage = () => {
                   cancelText: t('common.cancel'),
                   onOk: async () => {
                     await restartProxy({ clusterId: record.clusterId, addr: 
record.addr });
-                    await refreshClusters();
+                    await requestRefresh('operation');
                     message.success(t('cluster.restartProxySubmitted', { addr: 
record.addr }));
                   },
                 });
@@ -861,23 +886,49 @@ const ClusterPage = () => {
         title={t('cluster.title')}
         subtitle={`${t('common.total')} ${clusters.length} 
${t('cluster.title')} · ${totalBrokers} Broker · ${totalNameServers} NameServer 
· ${totalProxies} Proxy`}
         extra={
-          <Flex align="center" gap={6}>
-            {autoRefresh && (
-              <span
-                style={{
-                  width: 6,
-                  height: 6,
-                  borderRadius: '50%',
-                  background: '#52c41a',
-                  display: 'inline-block',
-                  animation: 'livePulse 1.5s ease-in-out infinite',
-                }}
+          <Flex align="center" gap={8}>
+            <Button
+              size="small"
+              icon={<ReloadOutlined spin={loading} />}
+              aria-label={t('common.refresh')}
+              onClick={() => void requestRefresh('manual')}
+            >
+              {t('common.refresh')}
+            </Button>
+            <Flex align="center" gap={6}>
+              {(autoRefresh || refreshFailed) && (
+                <span
+                  title={
+                    refreshFailed
+                      ? t('common.refreshFailed')
+                      : autoRefresh
+                        ? t('common.liveRefresh')
+                        : t('common.autoRefresh')
+                  }
+                  style={{
+                    width: 6,
+                    height: 6,
+                    borderRadius: '50%',
+                    background: refreshFailed ? '#ff4d4f' : '#52c41a',
+                    display: 'inline-block',
+                    animation: refreshFailed ? undefined : 'livePulse 1.5s 
ease-in-out infinite',
+                  }}
+                />
+              )}
+              <Text type={refreshFailed ? 'danger' : 'secondary'} style={{ 
fontSize: 12 }}>
+                {refreshFailed
+                  ? t('common.refreshFailed')
+                  : autoRefresh
+                    ? t('common.liveRefresh')
+                    : t('common.autoRefresh')}
+              </Text>
+              <Switch
+                size="small"
+                checked={autoRefresh}
+                aria-label={t('common.autoRefresh')}
+                onChange={handleAutoRefreshChange}
               />
-            )}
-            <Text type="secondary" style={{ fontSize: 12 }}>
-              {autoRefresh ? t('common.liveRefresh') : t('common.autoRefresh')}
-            </Text>
-            <Switch size="small" checked={autoRefresh} 
onChange={setAutoRefresh} />
+            </Flex>
           </Flex>
         }
       />
@@ -909,7 +960,7 @@ const ClusterPage = () => {
                 `${t('cluster.nsUpdated')}: ${values.addr}${values.newAddr ? ` 
→ ${values.newAddr}` : ''}`,
               );
             }
-            await refreshClusters();
+            await requestRefresh('operation');
             setNsModalOpen(false);
           });
         }}
diff --git a/web/src/services/clusterService.test.ts 
b/web/src/services/clusterService.test.ts
index a858a727..3e153313 100644
--- a/web/src/services/clusterService.test.ts
+++ b/web/src/services/clusterService.test.ts
@@ -22,7 +22,7 @@ vi.mock('../config', () => ({
   API_BASE_URL: '/api',
 }));
 
-import { getCluster, listClusters } from './clusterService';
+import { getCluster, listClusters, updateClusterConfig } from 
'./clusterService';
 
 describe('clusterService mock clusters', () => {
   it('returns defensive copies from cluster detail reads', async () => {
@@ -61,4 +61,28 @@ describe('clusterService mock clusters', () => {
     expect(detail.config).not.toBe(listed.config);
     expect(detail.tpsHistory).not.toBe(listed.tpsHistory);
   });
+
+  it('persists partial mock config updates without copying id into config', 
async () => {
+    const before = await getCluster('cluster-prod');
+    const originalConfig = { ...before.config };
+    const nextQueueCount = originalConfig.writeQueueNums + 1;
+
+    try {
+      await updateClusterConfig({
+        id: before.id,
+        writeQueueNums: nextQueueCount,
+      });
+
+      const updated = (await listClusters()).find((cluster) => cluster.id === 
before.id);
+      expect(updated?.config.writeQueueNums).toBe(nextQueueCount);
+      expect(updated?.config.readQueueNums).toBe(originalConfig.readQueueNums);
+      expect(updated?.config.flushDiskType).toBe(originalConfig.flushDiskType);
+      expect(updated?.config).not.toHaveProperty('id');
+    } finally {
+      await updateClusterConfig({
+        id: before.id,
+        ...originalConfig,
+      });
+    }
+  });
 });
diff --git a/web/src/services/clusterService.ts 
b/web/src/services/clusterService.ts
index fd9fcc75..374ad614 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -1,6 +1,6 @@
 import { USE_MOCK } from '../config';
 import * as clusterApi from '../api/cluster';
-import type { ClusterInfo, K8sCertInfo } from '../api/cluster';
+import type { ClusterConfig, ClusterInfo, K8sCertInfo } from '../api/cluster';
 import clusters, { mockK8sCerts } from '../mock/clusters';
 
 const mockCertStore: K8sCertInfo[] = mockK8sCerts.map((cert) => ({
@@ -110,8 +110,12 @@ export async function deleteK8sCert(id: string): 
Promise<void> {
   return clusterApi.deleteK8sCert(id);
 }
 
-export async function updateClusterConfig(data: { id: string } & 
Record<string, unknown>) {
-  if (USE_MOCK) return;
+export async function updateClusterConfig(data: { id: string } & 
Partial<ClusterConfig>) {
+  if (USE_MOCK) {
+    const { id, ...config } = data;
+    Object.assign(getMockCluster(id).config, config);
+    return;
+  }
   return clusterApi.updateClusterConfig(data);
 }
 

Reply via email to