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