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 f3464698 fix(ui): batch of frontend concurrency and correctness fixes
(#1352, #1354, #1355, #1356, #1357, #1358, #1359, #1360, #1361, #1362, #1363,
#1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372)
f3464698 is described below
commit f34646987bb79b4451013c52a8ee453f0002ec91
Author: lizhimins <[email protected]>
AuthorDate: Mon Aug 10 12:20:37 2026 +0800
fix(ui): batch of frontend concurrency and correctness fixes (#1352, #1354,
#1355, #1356, #1357, #1358, #1359, #1360, #1361, #1362, #1363, #1364, #1365,
#1366, #1367, #1368, #1369, #1370, #1371, #1372)
* fix(ui): remove duplicate cluster refresh timer
* fix(ui): ignore stale cluster list responses
* fix(ui): format IPv6 proxy endpoints
* fix(ui): preserve unknown broker status
* fix(ui): ignore stale Proxy list responses
* fix(ui): await Proxy mutation refreshes
* fix(consumer): scope global detail diagnostics
* fix(consumer): show offline group details
* fix(consumer): coalesce group refreshes
* fix(consumer): label manual reload as refresh
* fix(consumer): use instance-scoped row keys
* fix(ops): serialize channel setting updates
* fix(grafana): isolate dashboard preview requests
* fix(alerts): isolate asset preview requests
* fix(assets): track concurrent exports independently
* fix(acl): load rules and users independently
* fix(acl): preserve missing creation timestamps
* fix(alerts): handle unknown system alert levels
* fix(alerts): track concurrent acknowledgements
* fix(certs): track concurrent renewals
---------
Co-authored-by: Yue Wang <[email protected]>
Co-authored-by: yyqdbngt <[email protected]>
---
web/src/api/acl.ts | 4 +-
web/src/components/AlertRuleAssetList.tsx | 41 ++++++--
web/src/components/GrafanaDashboardList.tsx | 41 ++++++--
.../__tests__/AlertRuleAssetList.test.tsx | 50 +++++++++-
.../__tests__/GrafanaDashboardList.test.tsx | 61 +++++++++++-
.../pages/cluster/__tests__/K8sCertsPage.test.tsx | 30 +++++-
web/src/pages/cluster/certs.tsx | 12 ++-
web/src/pages/instance/__tests__/AclPage.test.tsx | 56 +++++++++++
web/src/pages/instance/acl.tsx | 38 +++++---
.../pages/ops/__tests__/SystemAlertsPage.test.tsx | 106 +++++++++++++++++++++
web/src/pages/ops/systemAlerts.tsx | 27 ++++--
web/src/pages/studio/BrokerCluster.tsx | 41 +++++---
web/src/pages/studio/GroupManagement.tsx | 68 +++++++++----
web/src/pages/studio/Ops.tsx | 28 +++++-
web/src/pages/studio/Proxy.tsx | 77 ++++++++-------
.../pages/studio/__tests__/BrokerCluster.test.tsx | 75 ++++++++++++++-
.../studio/__tests__/GroupManagement.test.tsx | 82 ++++++++++++----
web/src/pages/studio/__tests__/Ops.test.tsx | 24 ++++-
web/src/pages/studio/__tests__/Proxy.test.tsx | 35 ++++++-
19 files changed, 744 insertions(+), 152 deletions(-)
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index 63b710dd..41fdb22b 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -11,7 +11,7 @@ export interface AclRule {
decision: string;
scope: string;
aclVersion: number | string;
- createdAt: string;
+ createdAt?: string | null;
}
export interface AclRuleQuery {
@@ -26,7 +26,7 @@ export interface AclUser {
secretKey: string;
admin: boolean;
clusters: string[];
- createdAt: string;
+ createdAt?: string | null;
}
export async function listAclRules(params?: AclRuleQuery) {
diff --git a/web/src/components/AlertRuleAssetList.tsx
b/web/src/components/AlertRuleAssetList.tsx
index 32b2a232..2614d001 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownloadSimple, Eye } from '@phosphor-icons/react';
@@ -43,7 +43,8 @@ export const AlertRuleAssetList: React.FC = () => {
const [viewing, setViewing] = useState<AlertRuleAssetInfo | null>(null);
const [viewContent, setViewContent] = useState('');
const [viewLoading, setViewLoading] = useState(false);
- const [exportingName, setExportingName] = useState<string | null>(null);
+ const viewRequestId = useRef(0);
+ const [exportingNames, setExportingNames] = useState<Set<string>>(() => new
Set());
useEffect(() => {
let cancelled = false;
@@ -60,22 +61,38 @@ export const AlertRuleAssetList: React.FC = () => {
void load();
return () => {
cancelled = true;
+ viewRequestId.current += 1;
};
}, [t, message]);
const handleView = async (info: AlertRuleAssetInfo) => {
+ const requestId = ++viewRequestId.current;
setViewing(info);
+ setViewContent('');
setViewLoading(true);
try {
const yaml = await getAlertRuleAsset(info.name);
- setViewContent(yaml);
+ if (requestId === viewRequestId.current) {
+ setViewContent(yaml);
+ }
} catch {
- message.error(t('alertAssets.loadFailed'));
+ if (requestId === viewRequestId.current) {
+ message.error(t('alertAssets.loadFailed'));
+ }
} finally {
- setViewLoading(false);
+ if (requestId === viewRequestId.current) {
+ setViewLoading(false);
+ }
}
};
+ const closeView = () => {
+ viewRequestId.current += 1;
+ setViewing(null);
+ setViewContent('');
+ setViewLoading(false);
+ };
+
const triggerDownload = (name: string, content: Blob | string) => {
const blob = typeof content === 'string' ? new Blob([content], { type:
'text/yaml' }) : content;
const url = URL.createObjectURL(blob);
@@ -87,7 +104,7 @@ export const AlertRuleAssetList: React.FC = () => {
};
const handleExport = async (info: AlertRuleAssetInfo) => {
- setExportingName(info.name);
+ setExportingNames((current) => new Set(current).add(info.name));
try {
const blob = await exportAlertRuleAsset(info.name);
triggerDownload(info.name, blob);
@@ -95,7 +112,11 @@ export const AlertRuleAssetList: React.FC = () => {
} catch {
message.error(t('alertAssets.exportFailed'));
} finally {
- setExportingName(null);
+ setExportingNames((current) => {
+ const next = new Set(current);
+ next.delete(info.name);
+ return next;
+ });
}
};
@@ -144,7 +165,7 @@ export const AlertRuleAssetList: React.FC = () => {
<Button
size="small"
icon={<DownloadSimple size={16} />}
- loading={exportingName === record.name}
+ loading={exportingNames.has(record.name)}
onClick={() => handleExport(record)}
>
{t('common.export')}
@@ -168,8 +189,8 @@ export const AlertRuleAssetList: React.FC = () => {
<Modal
title={viewing ? viewing.name : t('alertAssets.title')}
open={viewing !== null}
- footer={<Button onClick={() =>
setViewing(null)}>{t('common.close')}</Button>}
- onCancel={() => setViewing(null)}
+ footer={<Button onClick={closeView}>{t('common.close')}</Button>}
+ onCancel={closeView}
width={760}
destroyOnHidden
>
diff --git a/web/src/components/GrafanaDashboardList.tsx
b/web/src/components/GrafanaDashboardList.tsx
index 894fb22d..251ae6d3 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownloadSimple, Eye } from '@phosphor-icons/react';
@@ -37,7 +37,8 @@ export const GrafanaDashboardList: React.FC = () => {
const [viewing, setViewing] = useState<GrafanaDashboardInfo | null>(null);
const [viewContent, setViewContent] = useState('');
const [viewLoading, setViewLoading] = useState(false);
- const [exportingUid, setExportingUid] = useState<string | null>(null);
+ const viewRequestId = useRef(0);
+ const [exportingUids, setExportingUids] = useState<Set<string>>(() => new
Set());
useEffect(() => {
let cancelled = false;
@@ -54,22 +55,38 @@ export const GrafanaDashboardList: React.FC = () => {
void load();
return () => {
cancelled = true;
+ viewRequestId.current += 1;
};
}, [t, message]);
const handleView = async (info: GrafanaDashboardInfo) => {
+ const requestId = ++viewRequestId.current;
setViewing(info);
+ setViewContent('');
setViewLoading(true);
try {
const model = await getGrafanaDashboard(info.uid);
- setViewContent(JSON.stringify(model, null, 2));
+ if (requestId === viewRequestId.current) {
+ setViewContent(JSON.stringify(model, null, 2));
+ }
} catch {
- message.error(t('grafana.loadFailed'));
+ if (requestId === viewRequestId.current) {
+ message.error(t('grafana.loadFailed'));
+ }
} finally {
- setViewLoading(false);
+ if (requestId === viewRequestId.current) {
+ setViewLoading(false);
+ }
}
};
+ const closeView = () => {
+ viewRequestId.current += 1;
+ setViewing(null);
+ setViewContent('');
+ setViewLoading(false);
+ };
+
const triggerDownload = (uid: string, content: Blob | string) => {
const blob =
typeof content === 'string' ? new Blob([content], { type:
'application/json' }) : content;
@@ -82,7 +99,7 @@ export const GrafanaDashboardList: React.FC = () => {
};
const handleExport = async (info: GrafanaDashboardInfo) => {
- setExportingUid(info.uid);
+ setExportingUids((current) => new Set(current).add(info.uid));
try {
const blob = await exportGrafanaDashboard(info.uid);
triggerDownload(info.uid, blob);
@@ -90,7 +107,11 @@ export const GrafanaDashboardList: React.FC = () => {
} catch {
message.error(t('grafana.exportFailed'));
} finally {
- setExportingUid(null);
+ setExportingUids((current) => {
+ const next = new Set(current);
+ next.delete(info.uid);
+ return next;
+ });
}
};
@@ -133,7 +154,7 @@ export const GrafanaDashboardList: React.FC = () => {
<Button
size="small"
icon={<DownloadSimple size={16} />}
- loading={exportingUid === record.uid}
+ loading={exportingUids.has(record.uid)}
onClick={() => handleExport(record)}
>
{t('common.export')}
@@ -157,8 +178,8 @@ export const GrafanaDashboardList: React.FC = () => {
<Modal
title={viewing ? viewing.title : t('grafana.title')}
open={viewing !== null}
- footer={<Button onClick={() =>
setViewing(null)}>{t('common.close')}</Button>}
- onCancel={() => setViewing(null)}
+ footer={<Button onClick={closeView}>{t('common.close')}</Button>}
+ onCancel={closeView}
width={760}
destroyOnHidden
>
diff --git a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
index cf606cdc..5da6d383 100644
--- a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
+++ b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
@@ -16,7 +16,7 @@
*/
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import { App as AntdApp } from 'antd';
import AlertRuleAssetList from '../AlertRuleAssetList';
import { LangProvider } from '../../i18n/LangContext';
@@ -97,6 +97,54 @@ describe('AlertRuleAssetList', () => {
expect(alertRuleAssetService.getAlertRuleAsset).toHaveBeenCalledWith('rocketmq-broker-down');
});
+ it('keeps the latest preview when an earlier request resolves last', async
() => {
+
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
+ let resolveBroker!: (value: string) => void;
+ let resolveConsumer!: (value: string) => void;
+ vi.mocked(alertRuleAssetService.getAlertRuleAsset).mockImplementation(
+ (name) =>
+ new Promise((resolve) => {
+ if (name === 'rocketmq-broker-down') resolveBroker = resolve;
+ else resolveConsumer = resolve;
+ }),
+ );
+ renderWithProviders(<AlertRuleAssetList />);
+
+ const viewButtons = await screen.findAllByRole('button', { name: /查看|View/
});
+ fireEvent.click(viewButtons[0]);
+ fireEvent.click(viewButtons[1]);
+
+ await act(async () => {
+ resolveConsumer('alert: LATEST_CONSUMER_ALERT');
+ });
+ const dialog = await screen.findByRole('dialog');
+
expect(within(dialog).getByText(/LATEST_CONSUMER_ALERT/)).toBeInTheDocument();
+
+ await act(async () => {
+ resolveBroker('alert: STALE_BROKER_ALERT');
+ });
+
expect(within(dialog).getByText(/LATEST_CONSUMER_ALERT/)).toBeInTheDocument();
+
expect(within(dialog).queryByText(/STALE_BROKER_ALERT/)).not.toBeInTheDocument();
+ });
+
+ it('tracks simultaneous asset exports independently', async () => {
+
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
+ vi.mocked(alertRuleAssetService.exportAlertRuleAsset).mockImplementation(
+ () => new Promise(() => {}),
+ );
+ renderWithProviders(<AlertRuleAssetList />);
+
+ const exportButtons = await screen.findAllByRole('button', { name:
/导出|Export/ });
+ fireEvent.click(exportButtons[0]);
+ fireEvent.click(exportButtons[1]);
+
+ await waitFor(() => {
+
expect(alertRuleAssetService.exportAlertRuleAsset).toHaveBeenCalledTimes(2);
+ expect(exportButtons[0]).toHaveClass('ant-btn-loading');
+ expect(exportButtons[1]).toHaveClass('ant-btn-loading');
+ });
+ });
+
it('downloads the yaml when Export is clicked', async () => {
const createObjectURLSpy = vi.spyOn(URL,
'createObjectURL').mockReturnValue('blob:url');
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(()
=> {});
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
index cc2c24f9..618ecb4c 100644
--- a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -16,7 +16,7 @@
*/
import { App } from 'antd';
-import { render, screen, waitFor, within } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
@@ -112,6 +112,65 @@ describe('GrafanaDashboardList', () => {
expect(within(dialog).getByText(/"uid":
"rocketmq-overview"/)).toBeInTheDocument();
});
+ it('keeps the latest preview when an earlier request resolves last', async
() => {
+ let resolveOverview!: (value: typeof dashboardModel) => void;
+ let resolveBroker!: (value: typeof dashboardModel) => void;
+ vi.mocked(getGrafanaDashboard).mockImplementation(
+ (uid) =>
+ new Promise((resolve) => {
+ if (uid === 'rocketmq-overview') resolveOverview = resolve;
+ else resolveBroker = resolve;
+ }),
+ );
+ render(
+ <App>
+ <LangProvider>
+ <GrafanaDashboardList />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('RocketMQ Cluster Overview');
+ const viewButtons = screen.getAllByRole('button', { name: /View|查看/ });
+ await userEvent.click(viewButtons[0]);
+ await userEvent.click(viewButtons[1]);
+
+ await act(async () => {
+ resolveBroker({ ...dashboardModel, uid: 'rocketmq-broker', title:
'RocketMQ Broker' });
+ });
+ const dialog = await screen.findByRole('dialog');
+ expect(within(dialog).getByText(/"uid":
"rocketmq-broker"/)).toBeInTheDocument();
+
+ await act(async () => {
+ resolveOverview(dashboardModel);
+ });
+ expect(within(dialog).getByText(/"uid":
"rocketmq-broker"/)).toBeInTheDocument();
+ expect(within(dialog).queryByText(/"uid":
"rocketmq-overview"/)).not.toBeInTheDocument();
+ });
+
+ it('tracks simultaneous dashboard exports independently', async () => {
+ vi.mocked(exportGrafanaDashboard).mockImplementation(() => new Promise(()
=> {}));
+ const user = userEvent.setup();
+ render(
+ <App>
+ <LangProvider>
+ <GrafanaDashboardList />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('RocketMQ Cluster Overview');
+ const exportButtons = screen.getAllByRole('button', { name: /Export|导出/ });
+ await user.click(exportButtons[0]);
+ await user.click(exportButtons[1]);
+
+ await waitFor(() => {
+ expect(exportGrafanaDashboard).toHaveBeenCalledTimes(2);
+ expect(exportButtons[0]).toHaveClass('ant-btn-loading');
+ expect(exportButtons[1]).toHaveClass('ant-btn-loading');
+ });
+ });
+
it('exports a dashboard and triggers a download', async () => {
const user = userEvent.setup();
const createObjectURL = vi.fn().mockReturnValue('blob:grafana');
diff --git a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
index bd6eccb2..6b4903b5 100644
--- a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
@@ -16,11 +16,11 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { App } from 'antd';
+import { App, Modal } from 'antd';
import type { K8sCertInfo } from '../../../api/cluster';
-import { listK8sCerts } from '../../../services/clusterService';
+import { listK8sCerts, renewK8sCert } from '../../../services/clusterService';
import K8sCertsPage from '../certs';
vi.mock('../../../services/clusterService', () => ({
@@ -79,6 +79,7 @@ beforeAll(() => {
describe('K8sCertsPage', () => {
beforeEach(() => {
+ vi.clearAllMocks();
vi.mocked(listK8sCerts).mockResolvedValue(certs);
});
@@ -137,6 +138,29 @@ describe('K8sCertsPage', () => {
expect(screen.queryByText('rocketmq-prod-tls')).not.toBeInTheDocument();
});
+ it('tracks simultaneous certificate renewals independently', async () => {
+ vi.mocked(renewK8sCert).mockImplementation(() => new Promise(() => {}));
+ const confirmSpy = vi.spyOn(Modal, 'confirm');
+ renderPage();
+
+ await screen.findByText('rocketmq-prod-tls');
+ const renewButtons = screen.getAllByRole('button', { name: /续期/ });
+ fireEvent.click(renewButtons[0]);
+ fireEvent.click(renewButtons[1]);
+ expect(confirmSpy).toHaveBeenCalledTimes(2);
+
+ void confirmSpy.mock.calls[0][0].onOk?.(() => {});
+ void confirmSpy.mock.calls[1][0].onOk?.(() => {});
+
+ await waitFor(() => {
+ expect(renewK8sCert).toHaveBeenCalledWith('cert-prod');
+ expect(renewK8sCert).toHaveBeenCalledWith('cert-staging');
+ expect(renewButtons[0]).toHaveClass('ant-btn-loading');
+ expect(renewButtons[1]).toHaveClass('ant-btn-loading');
+ });
+ confirmSpy.mockRestore();
+ });
+
it('trims certificate search text before filtering', async () => {
const user = userEvent.setup();
renderPage();
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index e0b95bdc..400db782 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -57,7 +57,7 @@ const K8sCertsPage = () => {
const [certs, setCerts] = useState<K8sCertInfo[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
- const [renewingId, setRenewingId] = useState<string | null>(null);
+ const [renewingIds, setRenewingIds] = useState<Set<string>>(() => new Set());
const [certSearch, setCertSearch] = useState('');
const [certTypeFilter, setCertTypeFilter] = useState<string>('');
const [certNamespaceFilter, setCertNamespaceFilter] = useState<string>('');
@@ -146,7 +146,7 @@ const K8sCertsPage = () => {
});
const renewCert = async (cert: K8sCertInfo) => {
- setRenewingId(cert.id);
+ setRenewingIds((current) => new Set(current).add(cert.id));
try {
const renewed = await renewK8sCert(cert.id);
setCerts((prev) => prev.map((item) => (item.id === renewed.id ? renewed
: item)));
@@ -155,7 +155,11 @@ const K8sCertsPage = () => {
message.error(getErrorMessage(error));
throw error;
} finally {
- setRenewingId(null);
+ setRenewingIds((current) => {
+ const next = new Set(current);
+ next.delete(cert.id);
+ return next;
+ });
}
};
@@ -297,7 +301,7 @@ const K8sCertsPage = () => {
<Button
size="small"
icon={<SyncOutlined />}
- loading={renewingId === record.id}
+ loading={renewingIds.has(record.id)}
onClick={() => {
Modal.confirm({
title: '确认续期',
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index bd4b4104..a67a9725 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -103,6 +103,24 @@ describe('ACL page', () => {
expect(aclService.listAclUsers).toHaveBeenCalledTimes(1);
});
+ it('keeps rules available when loading users fails', async () => {
+ vi.mocked(aclService.listAclUsers).mockRejectedValue(new Error('users
unavailable'));
+ renderWithProviders(<AclPage />);
+
+ expect(await screen.findByText('remote-user')).toBeInTheDocument();
+ expect(screen.getByText('remote-topic')).toBeInTheDocument();
+ });
+
+ it('keeps users available when loading rules fails', async () => {
+ const user = userEvent.setup();
+ vi.mocked(aclService.listAclRules).mockRejectedValue(new Error('rules
unavailable'));
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('用户管理'));
+ expect(await screen.findByText('remote-admin')).toBeInTheDocument();
+ expect(screen.getByText('cluster-a')).toBeInTheDocument();
+ });
+
it('renders backend users on the user tab', async () => {
const user = userEvent.setup();
renderWithProviders(<AclPage />);
@@ -113,6 +131,44 @@ describe('ACL page', () => {
expect(screen.getByText('cluster-a')).toBeInTheDocument();
});
+ it('shows missing backend timestamps as unavailable', async () => {
+ const user = userEvent.setup();
+ vi.mocked(aclService.listAclRules).mockResolvedValue([
+ {
+ id: 'rule-without-time',
+ principal: 'no-time-rule',
+ resource: 'topic-a',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: 2,
+ createdAt: null,
+ },
+ ]);
+ vi.mocked(aclService.listAclUsers).mockResolvedValue([
+ {
+ id: 'user-without-time',
+ username: 'no-time-user',
+ accessKey: 'acce****3456',
+ secretKey: 'secr****7654',
+ admin: false,
+ clusters: [],
+ createdAt: null,
+ },
+ ]);
+ renderWithProviders(<AclPage />);
+
+ expect(await screen.findByText('no-time-rule')).toBeInTheDocument();
+ expect(screen.getByText('-')).toBeInTheDocument();
+
+ await user.click(screen.getByText('用户管理'));
+ const userPanel = screen.getByRole('tabpanel', { name: '用户管理' });
+ expect(await
within(userPanel).findByText('no-time-user')).toBeInTheDocument();
+ expect(within(userPanel).getByText('-')).toBeInTheDocument();
+ });
+
it('does not submit masked credentials when editing a user', async () => {
const user = userEvent.setup();
vi.mocked(aclService.updateAclUser).mockResolvedValue({
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 85ae8a80..e6e3e469 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -70,7 +70,7 @@ const normalizeRule = (rule: AclRule): AclRule => ({
decision: rule.decision ?? '',
scope: rule.scope ?? '',
aclVersion: rule.aclVersion ?? '2.0',
- createdAt: rule.createdAt ?? new Date().toISOString(),
+ createdAt: rule.createdAt ?? null,
});
const normalizeUser = (user: AclUser): AclUser => ({
@@ -80,7 +80,7 @@ const normalizeUser = (user: AclUser): AclUser => ({
secretKey: user.secretKey ?? '',
admin: user.admin ?? false,
clusters: user.clusters ?? [],
- createdAt: user.createdAt ?? new Date().toISOString(),
+ createdAt: user.createdAt ?? null,
});
const isFormValidationError = (error: unknown) =>
@@ -126,19 +126,26 @@ const AclPage = () => {
useEffect(() => {
let mounted = true;
- Promise.all([listAclRules(), listAclUsers()])
- .then(([nextRules, nextUsers]) => {
- if (!mounted) return;
- setRules(nextRules.map(normalizeRule));
- setUsers(nextUsers.map(normalizeUser));
+ void listAclRules()
+ .then((nextRules) => {
+ if (mounted) setRules(nextRules.map(normalizeRule));
})
.catch(() => {
if (mounted) message.error(t('common.fetchDataFailed'));
})
.finally(() => {
- if (!mounted) return;
- setRulesLoading(false);
- setUsersLoading(false);
+ if (mounted) setRulesLoading(false);
+ });
+
+ void listAclUsers()
+ .then((nextUsers) => {
+ if (mounted) setUsers(nextUsers.map(normalizeUser));
+ })
+ .catch(() => {
+ if (mounted) message.error(t('common.fetchDataFailed'));
+ })
+ .finally(() => {
+ if (mounted) setUsersLoading(false);
});
return () => {
@@ -343,7 +350,8 @@ const AclPage = () => {
}
};
- const formatDate = (iso: string) => {
+ const formatDate = (iso?: string | null) => {
+ if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2,
'0')}-${String(d.getDate()).padStart(2, '0')}
${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2,
'0')}`;
@@ -443,8 +451,8 @@ const AclPage = () => {
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
- sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
- render: (iso: string) => (
+ sorter: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
+ render: (iso?: string | null) => (
<span style={{ fontSize: 13, color: '#8c8c8c'
}}>{formatDate(iso)}</span>
),
},
@@ -588,8 +596,8 @@ const AclPage = () => {
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
- sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
- render: (iso: string) => (
+ sorter: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
+ render: (iso?: string | null) => (
<span style={{ fontSize: 13, color: '#8c8c8c'
}}>{formatDate(iso)}</span>
),
},
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
new file mode 100644
index 00000000..52f7d17e
--- /dev/null
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { LangProvider } from '../../../i18n/LangContext';
+import { acknowledgeAlert, listSystemAlerts } from
'../../../services/opsService';
+import SystemAlertsPage from '../systemAlerts';
+
+vi.mock('../../../services/opsService', () => ({
+ acknowledgeAlert: vi.fn(),
+ clearAcknowledgedAlerts: vi.fn(),
+ listSystemAlerts: 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(),
+ })),
+ });
+});
+
+const renderPage = () =>
+ render(
+ <App>
+ <LangProvider>
+ <SystemAlertsPage />
+ </LangProvider>
+ </App>,
+ );
+
+describe('SystemAlertsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listSystemAlerts).mockResolvedValue([
+ {
+ id: 'alert-a',
+ level: 'error',
+ title: 'Broker unavailable',
+ description: 'broker a',
+ time: '2026-08-10 01:00',
+ acknowledged: false,
+ },
+ {
+ id: 'alert-b',
+ level: 'warning',
+ title: 'Consumer lag',
+ description: 'consumer b',
+ time: '2026-08-10 01:01',
+ acknowledged: false,
+ },
+ ]);
+ });
+
+ it('renders an alert with an unknown backend level', async () => {
+ vi.mocked(listSystemAlerts).mockResolvedValue([
+ {
+ id: 'alert-critical',
+ level: 'critical',
+ title: 'Critical broker condition',
+ description: 'A newer backend emitted this level',
+ time: '2026-08-10 01:00',
+ acknowledged: false,
+ },
+ ]);
+
+ renderPage();
+
+ expect(await screen.findByText('Critical broker
condition')).toBeInTheDocument();
+ expect(screen.getByText('critical')).toBeInTheDocument();
+ expect(screen.getByText('A newer backend emitted this
level')).toBeInTheDocument();
+ });
+
+ it('tracks simultaneous acknowledgements independently', async () => {
+ vi.mocked(acknowledgeAlert).mockImplementation(() => new Promise(() =>
{}));
+ const user = userEvent.setup();
+ renderPage();
+
+ await screen.findByText('Broker unavailable');
+ const acknowledgeButtons = screen.getAllByRole('button', { name: /^确认$/ });
+ await user.click(acknowledgeButtons[0]);
+ await user.click(acknowledgeButtons[1]);
+
+ await waitFor(() => {
+ expect(acknowledgeAlert).toHaveBeenCalledWith('alert-a');
+ expect(acknowledgeAlert).toHaveBeenCalledWith('alert-b');
+ expect(acknowledgeButtons[0]).toHaveClass('ant-btn-loading');
+ expect(acknowledgeButtons[1]).toHaveClass('ant-btn-loading');
+ });
+ });
+});
diff --git a/web/src/pages/ops/systemAlerts.tsx
b/web/src/pages/ops/systemAlerts.tsx
index 09e59041..8f73da59 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -41,7 +41,7 @@ const SystemAlertsPage = () => {
const [alerts, setAlerts] = useState<SystemAlert[]>([]);
const [levelFilter, setLevelFilter] = useState<string>('all');
const [loading, setLoading] = useState(true);
- const [acknowledgingId, setAcknowledgingId] = useState<string | null>(null);
+ const [acknowledgingIds, setAcknowledgingIds] = useState<Set<string>>(() =>
new Set());
const [clearing, setClearing] = useState(false);
useEffect(() => {
@@ -68,7 +68,7 @@ const SystemAlertsPage = () => {
const unackCount = alerts.filter((a) => !a.acknowledged).length;
const handleAck = async (id: string) => {
- setAcknowledgingId(id);
+ setAcknowledgingIds((current) => new Set(current).add(id));
try {
await acknowledgeAlert(id);
setAlerts((prev) => prev.map((a) => (a.id === id ? { ...a, acknowledged:
true } : a)));
@@ -76,7 +76,11 @@ const SystemAlertsPage = () => {
} catch {
message.error('确认告警失败,请稍后重试');
} finally {
- setAcknowledgingId(null);
+ setAcknowledgingIds((current) => {
+ const next = new Set(current);
+ next.delete(id);
+ return next;
+ });
}
};
@@ -138,7 +142,12 @@ const SystemAlertsPage = () => {
{loading && <Card loading />}
{!loading &&
filtered.map((alert) => {
- const cfg = alertLevelConfig[alert.level];
+ const normalizedLevel = alert.level.toLowerCase();
+ const cfg = alertLevelConfig[normalizedLevel] ?? {
+ color: '#8c8c8c',
+ bg: '#fafafa',
+ label: alert.level || t('common.na'),
+ };
return (
<div
key={alert.id}
@@ -160,11 +169,13 @@ const SystemAlertsPage = () => {
</Text>
<Tag
color={
- alert.level === 'error'
+ normalizedLevel === 'error'
? 'error'
- : alert.level === 'warning'
+ : normalizedLevel === 'warning'
? 'warning'
- : 'processing'
+ : normalizedLevel === 'info'
+ ? 'processing'
+ : 'default'
}
style={{ fontSize: 11, lineHeight: '18px', padding: '0
6px' }}
>
@@ -185,7 +196,7 @@ const SystemAlertsPage = () => {
type="link"
icon={<CheckCircle size={14} />}
onClick={() => handleAck(alert.id)}
- loading={acknowledgingId === alert.id}
+ loading={acknowledgingIds.has(alert.id)}
>
{t('sysAlerts.acknowledge')}
</Button>
diff --git a/web/src/pages/studio/BrokerCluster.tsx
b/web/src/pages/studio/BrokerCluster.tsx
index 702af537..e9f1b549 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import {
Table,
Button,
@@ -42,7 +42,7 @@ import { listClusters, restartBroker } from
'../../services/clusterService';
import type { ClusterInfo } from '../../api/cluster';
// ─── Types ──────────────────────────────────────────────────────
-type NodeStatus = 'running' | 'readonly' | 'maintenance';
+type NodeStatus = 'running' | 'readonly' | 'maintenance' | 'unknown';
const REFRESH_INTERVAL_MS = 2000;
@@ -85,10 +85,20 @@ const normalizeStatus = (status: string): NodeStatus => {
const value = (status || '').toLowerCase();
if (value === 'readonly' || value === 'warning') return 'readonly';
if (value === 'maintenance' || value === 'error' || value === 'offline')
return 'maintenance';
- return 'running';
+ if (value === 'running' || value === 'healthy') return 'running';
+ return 'unknown';
};
-const hostOf = (addr: string): string => addr.split(':')[0] ?? addr;
+const hostOf = (addr: string): string => {
+ const value = addr.trim();
+ if (value.startsWith('[')) {
+ const closingBracket = value.indexOf(']');
+ return closingBracket >= 0 ? value.slice(0, closingBracket + 1) : value;
+ }
+ const firstColon = value.indexOf(':');
+ const lastColon = value.lastIndexOf(':');
+ return firstColon >= 0 && firstColon === lastColon ? value.slice(0,
lastColon) : value;
+};
function mapClusters(clusters: ClusterInfo[]): {
brokers: BrokerRecord[];
@@ -155,33 +165,30 @@ const BrokerClusterPage = () => {
const [brokerData, setBrokerData] = useState<BrokerRecord[]>([]);
const [nameServerData, setNameServerData] = useState<NameServerRecord[]>([]);
const [proxyData, setProxyData] = useState<ProxyRecord[]>([]);
+ const loadRequestId = useRef(0);
const { t } = useLang();
const { message } = App.useApp();
const loadData = useCallback(async () => {
+ const requestId = ++loadRequestId.current;
setLoading(true);
try {
const clusters = await listClusters();
+ if (requestId !== loadRequestId.current) return;
const mapped = mapClusters(clusters);
setBrokerData(mapped.brokers);
setNameServerData(mapped.nameServers);
setProxyData(mapped.proxies);
} catch {
+ if (requestId !== loadRequestId.current) return;
message.error(t('common.refreshFailed'));
} finally {
- setLoading(false);
+ if (requestId === loadRequestId.current) {
+ setLoading(false);
+ }
}
}, [message, t]);
- // Live refresh: poll while the auto-refresh switch is on.
- useEffect(() => {
- if (!autoRefresh) return;
- const timer = setInterval(() => {
- void loadData();
- }, 5000);
- return () => clearInterval(timer);
- }, [autoRefresh, loadData]);
-
const handleRestartBroker = async (broker: BrokerRecord) => {
try {
const result = await restartBroker(broker.clusterId, broker.brokerName);
@@ -202,7 +209,10 @@ const BrokerClusterPage = () => {
const timeoutId = window.setTimeout(() => {
void loadData();
});
- return () => window.clearTimeout(timeoutId);
+ return () => {
+ window.clearTimeout(timeoutId);
+ ++loadRequestId.current;
+ };
}, [loadData]);
useEffect(() => {
@@ -222,6 +232,7 @@ const BrokerClusterPage = () => {
color: 'error',
label: t('brokerCluster.statusMaintenance'),
},
+ unknown: { color: 'default', label: t('common.na') },
};
const { color, label } = config[status] || config.running;
return <Tag color={color}>{label}</Tag>;
diff --git a/web/src/pages/studio/GroupManagement.tsx
b/web/src/pages/studio/GroupManagement.tsx
index 9948beb9..4459e2d1 100644
--- a/web/src/pages/studio/GroupManagement.tsx
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -67,31 +67,54 @@ const GroupManagementPage = () => {
const [progress, setProgress] = useState<QueueProgress[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const listRequestId = useRef(0);
+ const listInFlight = useRef<Promise<void> | null>(null);
+ const listRefreshQueued = useRef(false);
+ const mountedRef = useRef(true);
const detailRequestId = useRef(0);
const { t } = useLang();
- const loadGroups = useCallback(async () => {
- const requestId = ++listRequestId.current;
+ const loadGroups = useCallback((): Promise<void> => {
+ if (listInFlight.current) {
+ listRefreshQueued.current = true;
+ return listInFlight.current;
+ }
+
setLoading(true);
- try {
- const data = await listConsumerGroups();
- if (requestId !== listRequestId.current) return;
- setGroups(data);
- } catch {
- if (requestId !== listRequestId.current) return;
- message.error(t('consumer.fetchListFailed'));
- } finally {
- if (requestId === listRequestId.current) {
+ const run = async () => {
+ do {
+ listRefreshQueued.current = false;
+ const requestId = ++listRequestId.current;
+ try {
+ const data = await listConsumerGroups();
+ if (!mountedRef.current || requestId !== listRequestId.current)
return;
+ setGroups(data);
+ } catch {
+ if (!mountedRef.current || requestId !== listRequestId.current)
return;
+ message.error(t('consumer.fetchListFailed'));
+ }
+ } while (mountedRef.current && listRefreshQueued.current);
+ };
+
+ const cycle = run().finally(() => {
+ listInFlight.current = null;
+ if (mountedRef.current) {
setLoading(false);
}
- }
+ });
+ listInFlight.current = cycle;
+ return cycle;
}, [t]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadGroups();
});
- return () => window.clearTimeout(timeoutId);
+ return () => {
+ window.clearTimeout(timeoutId);
+ mountedRef.current = false;
+ listRefreshQueued.current = false;
+ ++listRequestId.current;
+ };
}, [loadGroups]);
useEffect(() => {
@@ -117,8 +140,8 @@ const GroupManagementPage = () => {
setDetailLoading(true);
try {
const [subs, prog] = await Promise.all([
- getConsumerSubscriptions(group.name),
- getConsumerProgress(group.name),
+ getConsumerSubscriptions(group.name, group.instanceId),
+ getConsumerProgress(group.name, group.instanceId),
]);
if (requestId !== detailRequestId.current) return;
setSubscriptions(subs);
@@ -286,7 +309,7 @@ const GroupManagementPage = () => {
size="small"
/>
<Button icon={<ArrowClockwise size={14} />} size="small"
onClick={handleRefresh}>
- {t('common.reset')}
+ {t('common.refresh')}
</Button>
</Space>
</div>
@@ -295,7 +318,9 @@ const GroupManagementPage = () => {
<Table
columns={columns}
dataSource={filteredGroupData}
- rowKey="name"
+ rowKey={(record) =>
+ `${record.instanceId || record.clusterId ||
'unscoped'}\0${record.name}`
+ }
loading={loading}
pagination={{
pageSize: 10,
@@ -340,8 +365,13 @@ const GroupManagementPage = () => {
</div>
<div style={{ fontSize: 24, fontWeight: 600 }}>
{selectedGroup.onlineInstances}{' '}
- <Tag color="success" style={{ marginLeft: 8 }}>
- {t('groupMgmt.online')}
+ <Tag
+ color={selectedGroup.onlineInstances > 0 ?
'success' : 'error'}
+ style={{ marginLeft: 8 }}
+ >
+ {selectedGroup.onlineInstances > 0
+ ? t('groupMgmt.online')
+ : t('groupMgmt.stopped')}
</Tag>
</div>
</Card>
diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx
index f5320c5d..d042f1d2 100644
--- a/web/src/pages/studio/Ops.tsx
+++ b/web/src/pages/studio/Ops.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { Alert, App, Button, Input, Popconfirm, Select, Space, Switch,
Tooltip, Typography } from 'antd';
import { FloppyDisk, Plus, Trash } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
@@ -42,6 +42,10 @@ const OpsPage: React.FC = () => {
const [newNamesrvAddr, setNewNamesrvAddr] = useState('');
const [useVIPChannel, setUseVIPChannel] = useState(false);
const [useTLS, setUseTLS] = useState(false);
+ const [vipUpdating, setVipUpdating] = useState(false);
+ const [tlsUpdating, setTlsUpdating] = useState(false);
+ const vipUpdateInFlight = useRef(false);
+ const tlsUpdateInFlight = useRef(false);
const [configurationAvailable, setConfigurationAvailable] = useState(false);
const [unavailableReason, setUnavailableReason] = useState('');
const writeOperationEnabled = configurationAvailable && (!token || admin ===
true);
@@ -121,6 +125,9 @@ const OpsPage: React.FC = () => {
};
const handleUpdateIsVIPChannel = async (checked: boolean) => {
+ if (vipUpdateInFlight.current) return;
+ vipUpdateInFlight.current = true;
+ setVipUpdating(true);
setUseVIPChannel(checked);
try {
await updateIsVIPChannel(checked);
@@ -128,10 +135,16 @@ const OpsPage: React.FC = () => {
} catch {
message.error(t('common.failure'));
setUseVIPChannel(!checked);
+ } finally {
+ vipUpdateInFlight.current = false;
+ setVipUpdating(false);
}
};
const handleUpdateUseTLS = async (checked: boolean) => {
+ if (tlsUpdateInFlight.current) return;
+ tlsUpdateInFlight.current = true;
+ setTlsUpdating(true);
setUseTLS(checked);
try {
await updateUseTLS(checked);
@@ -139,6 +152,9 @@ const OpsPage: React.FC = () => {
} catch {
message.error(t('common.failure'));
setUseTLS(!checked);
+ } finally {
+ tlsUpdateInFlight.current = false;
+ setTlsUpdating(false);
}
};
@@ -215,13 +231,16 @@ const OpsPage: React.FC = () => {
<Switch
checked={useVIPChannel}
onChange={handleUpdateIsVIPChannel}
- disabled={!writeOperationEnabled}
+ disabled={!writeOperationEnabled || vipUpdating}
+ loading={vipUpdating}
/>
{writeOperationEnabled && (
<Button
type="primary"
icon={<FloppyDisk size={16} />}
onClick={() => handleUpdateIsVIPChannel(useVIPChannel)}
+ loading={vipUpdating}
+ disabled={vipUpdating}
>
{t('common.update')}
</Button>
@@ -236,13 +255,16 @@ const OpsPage: React.FC = () => {
<Switch
checked={useTLS}
onChange={handleUpdateUseTLS}
- disabled={!writeOperationEnabled}
+ disabled={!writeOperationEnabled || tlsUpdating}
+ loading={tlsUpdating}
/>
{writeOperationEnabled && (
<Button
type="primary"
icon={<FloppyDisk size={16} />}
onClick={() => handleUpdateUseTLS(useTLS)}
+ loading={tlsUpdating}
+ disabled={tlsUpdating}
>
{t('common.update')}
</Button>
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 548c836f..7561e731 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import {
Card,
Table,
@@ -63,6 +63,7 @@ const ProxyPage: React.FC = () => {
const [configModalOpen, setConfigModalOpen] = useState(false);
const [addNodeModalOpen, setAddNodeModalOpen] = useState(false);
const [form] = Form.useForm();
+ const loadRequestId = useRef(0);
const [clusterStats, setClusterStats] = useState({
totalNodes: 0,
@@ -72,9 +73,11 @@ const ProxyPage: React.FC = () => {
});
const loadProxyNodes = useCallback(async () => {
+ const requestId = ++loadRequestId.current;
setLoading(true);
try {
const { proxyAddrList, currentProxyAddr } = await queryProxyHomePage();
+ if (requestId !== loadRequestId.current) return false;
const nodes: ProxyNode[] = (proxyAddrList || []).map((addr) => ({
key: addr,
address: addr,
@@ -103,10 +106,13 @@ const ProxyPage: React.FC = () => {
}
return true;
} catch {
+ if (requestId !== loadRequestId.current) return false;
message.error(t('proxy.fetchListFailed'));
return false;
} finally {
- setLoading(false);
+ if (requestId === loadRequestId.current) {
+ setLoading(false);
+ }
}
}, [message, t]);
@@ -114,6 +120,9 @@ const ProxyPage: React.FC = () => {
// The state updates are performed by the asynchronous Proxy API request,
not by this effect itself.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadProxyNodes();
+ return () => {
+ ++loadRequestId.current;
+ };
}, [loadProxyNodes]);
const handleViewConfig = (node: ProxyNode) => {
@@ -121,43 +130,39 @@ const ProxyPage: React.FC = () => {
setConfigModalOpen(true);
};
- const handleAddNode = () => {
- form
- .validateFields()
- .then((values) => {
- setLoading(true);
- addProxyAddr(values.address)
- .then(() => {
- message.success(t('common.success'));
- setAddNodeModalOpen(false);
- form.resetFields();
- loadProxyNodes();
- })
- .catch(() => {
- message.error(t('proxy.addFailed'));
- })
- .finally(() => {
- setLoading(false);
- });
- })
- .catch(() => {
- // validation failed
- });
+ const handleAddNode = async () => {
+ let values: { address: string };
+ try {
+ values = await form.validateFields();
+ } catch {
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await addProxyAddr(values.address);
+ message.success(t('common.success'));
+ setAddNodeModalOpen(false);
+ form.resetFields();
+ await loadProxyNodes();
+ } catch {
+ message.error(t('proxy.addFailed'));
+ } finally {
+ setLoading(false);
+ }
};
- const handleRemoveNode = (node: ProxyNode) => {
+ const handleRemoveNode = async (node: ProxyNode) => {
setLoading(true);
- removeProxyAddr(node.address)
- .then(() => {
- message.success(t('common.success'));
- loadProxyNodes();
- })
- .catch(() => {
- message.error(t('proxy.removeFailed'));
- })
- .finally(() => {
- setLoading(false);
- });
+ try {
+ await removeProxyAddr(node.address);
+ message.success(t('common.success'));
+ await loadProxyNodes();
+ } catch {
+ message.error(t('proxy.removeFailed'));
+ } finally {
+ setLoading(false);
+ }
};
const handleRefresh = async () => {
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 08c82779..9eb034a0 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -117,6 +117,14 @@ const renderWithProviders = (ui: React.ReactElement) => {
);
};
+const createDeferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
describe('BrokerCluster Page', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -227,14 +235,75 @@ describe('BrokerCluster Page', () => {
const liveRefreshSwitch = screen.getByRole('switch');
fireEvent.click(liveRefreshSwitch);
await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
+ await vi.advanceTimersByTimeAsync(6000);
});
- expect(listClusters).toHaveBeenCalledTimes(2);
+ expect(listClusters).toHaveBeenCalledTimes(4);
fireEvent.click(liveRefreshSwitch);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
});
- expect(listClusters).toHaveBeenCalledTimes(2);
+ expect(listClusters).toHaveBeenCalledTimes(4);
});
+ it('keeps the latest cluster list when an older refresh resolves last',
async () => {
+ const older = createDeferred<ClusterInfo[]>();
+ const latest = createDeferred<ClusterInfo[]>();
+ vi.mocked(listClusters)
+ .mockResolvedValueOnce(clusterFixture)
+ .mockReturnValueOnce(older.promise)
+ .mockReturnValueOnce(latest.promise);
+ const user = userEvent.setup();
+ renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-api-a');
+
+ const refreshButton = screen.getByText('重置');
+ await user.click(refreshButton);
+ await user.click(refreshButton);
+
+ const latestFixture = [
+ {
+ ...clusterFixture[0],
+ brokers: [{ ...clusterFixture[0].brokers[0], name: 'latest-broker' }],
+ },
+ ];
+ await act(async () => latest.resolve(latestFixture));
+ expect(await screen.findByText('latest-broker')).toBeInTheDocument();
+
+ await act(async () => older.resolve(clusterFixture));
+ expect(screen.getByText('latest-broker')).toBeInTheDocument();
+ expect(screen.queryByText('broker-api-a')).not.toBeInTheDocument();
+ });
+
+ it('formats bracketed IPv6 proxy gRPC endpoints without truncating the
host', async () => {
+ vi.mocked(listClusters).mockResolvedValue([
+ {
+ ...clusterFixture[0],
+ proxies: [
+ {
+ ...clusterFixture[0].proxies[0],
+ addr: '[2001:db8::10]:8080',
+ grpcPort: 8081,
+ },
+ ],
+ },
+ ]);
+ const user = userEvent.setup();
+ renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-api-a');
+ await user.click(screen.getByText('Proxy 管理'));
+
+ expect(screen.getByText('[2001:db8::10]:8081')).toBeInTheDocument();
+ });
+ it('renders unrecognized broker statuses as unavailable instead of running',
async () => {
+ vi.mocked(listClusters).mockResolvedValue([{
+ ...clusterFixture[0],
+ brokers: [{ ...clusterFixture[0].brokers[0], status: 'mystery' }],
+ }]);
+ renderWithProviders(<BrokerCluster />);
+
+ await screen.findByText('broker-api-a');
+ expect(screen.getByText('N/A')).toBeInTheDocument();
+ expect(screen.queryByText('运行中')).not.toBeInTheDocument();
+ });
+
});
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
index 4bc1f505..27068102 100644
--- a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
@@ -16,7 +16,7 @@
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from
'vitest';
-import { act, fireEvent, render, screen, waitFor } from
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
@@ -124,9 +124,9 @@ describe('GroupManagement Page', () => {
expect(screen.queryByText('查看分布')).not.toBeInTheDocument();
});
- it('should render reset button', () => {
+ it('should render refresh button', () => {
renderWithProviders(<GroupManagement />);
- expect(screen.getByText('重置')).toBeInTheDocument();
+ expect(screen.getByText('刷新')).toBeInTheDocument();
});
it('should display consumer group data from the service in table', async ()
=> {
@@ -198,32 +198,24 @@ describe('GroupManagement Page', () => {
expect(screen.queryByText('FIRST_GROUP_TOPIC')).not.toBeInTheDocument();
});
- it('keeps the latest group list when an earlier refresh resolves last',
async () => {
+ it('queues one refresh instead of overlapping an active group request',
async () => {
const initialGroups = createDeferred<ConsumerGroup[]>();
const refreshedGroups = createDeferred<ConsumerGroup[]>();
vi.mocked(consumerService.listConsumerGroups)
.mockReturnValueOnce(initialGroups.promise)
.mockReturnValueOnce(refreshedGroups.promise);
- vi.useFakeTimers();
renderWithProviders(<GroupManagement />);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(0);
- });
- fireEvent.click(screen.getByText('重置'));
+ await waitFor(() =>
expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1));
+ fireEvent.click(screen.getByText('刷新'));
+ expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
- await act(async () => {
- refreshedGroups.resolve([makeGroup({ name: 'fresh-group' })]);
- await Promise.resolve();
- });
- expect(screen.getByText('fresh-group')).toBeInTheDocument();
+ initialGroups.resolve([makeGroup({ name: 'initial-group' })]);
+ await waitFor(() =>
expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(2));
- await act(async () => {
- initialGroups.resolve([makeGroup({ name: 'stale-group' })]);
- await Promise.resolve();
- });
- expect(screen.getByText('fresh-group')).toBeInTheDocument();
- expect(screen.queryByText('stale-group')).not.toBeInTheDocument();
+ refreshedGroups.resolve([makeGroup({ name: 'fresh-group' })]);
+ expect(await screen.findByText('fresh-group')).toBeInTheDocument();
+ expect(screen.queryByText('initial-group')).not.toBeInTheDocument();
});
it('polls only while auto refresh is enabled', async () => {
@@ -260,4 +252,54 @@ describe('GroupManagement Page', () => {
expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
expect(screen.queryByText('payment-consumer-group')).not.toBeInTheDocument();
});
+ it('scopes global group detail diagnostics to the record instance', async ()
=> {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
+ makeGroup({ name: 'shared-group', instanceId: 'instance-b' }),
+ ]);
+ const user = userEvent.setup();
+ renderWithProviders(<GroupManagement />);
+ await screen.findByText('shared-group');
+ await user.click(screen.getByText('详情'));
+
+ await waitFor(() => {
+ expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith(
+ 'shared-group',
+ 'instance-b',
+ );
+ expect(consumerService.getConsumerProgress).toHaveBeenCalledWith(
+ 'shared-group',
+ 'instance-b',
+ );
+ });
+ });
+
+ it('shows a stopped status in details when no consumer instance is online',
async () => {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
+ makeGroup({ name: 'offline-group', onlineInstances: 0 }),
+ ]);
+ const user = userEvent.setup();
+ renderWithProviders(<GroupManagement />);
+ await screen.findByText('offline-group');
+ await user.click(screen.getByText('详情'));
+
+ const dialog = await screen.findByRole('dialog');
+ expect(within(dialog).getByText('已停止')).toBeInTheDocument();
+ expect(within(dialog).queryByText('在线')).not.toBeInTheDocument();
+ });
+
+ it('uses unique row keys for same-named groups from different instances',
async () => {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
+ makeGroup({ name: 'shared-group', instanceId: 'instance-a' }),
+ makeGroup({ name: 'shared-group', instanceId: 'instance-b' }),
+ ]);
+ const { container } = renderWithProviders(<GroupManagement />);
+ await screen.findAllByText('shared-group');
+
+ const rowKeys = Array.from(container.querySelectorAll('tbody
tr[data-row-key]')).map((row) =>
+ row.getAttribute('data-row-key'),
+ );
+ expect(rowKeys).toContain('instance-a\0shared-group');
+ expect(rowKeys).toContain('instance-b\0shared-group');
+ expect(new Set(rowKeys).size).toBe(rowKeys.length);
+ });
});
diff --git a/web/src/pages/studio/__tests__/Ops.test.tsx
b/web/src/pages/studio/__tests__/Ops.test.tsx
index beb12a8b..31c147da 100644
--- a/web/src/pages/studio/__tests__/Ops.test.tsx
+++ b/web/src/pages/studio/__tests__/Ops.test.tsx
@@ -22,7 +22,7 @@ import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import OpsPage from '../Ops';
-import { deleteNameSvrAddr, queryOpsHomePage } from '../../../api/ops';
+import { deleteNameSvrAddr, queryOpsHomePage, updateIsVIPChannel } from
'../../../api/ops';
import useAuthStore from '../../../stores/authStore';
vi.mock('../../../api/ops', () => ({
@@ -85,6 +85,28 @@ describe('OpsPage', () => {
expect(screen.getAllByRole('switch')[1]).not.toBeChecked();
});
+ it('prevents overlapping VIP channel updates', async () => {
+ let resolveUpdate!: () => void;
+ vi.mocked(updateIsVIPChannel).mockReturnValue(
+ new Promise<void>((resolve) => {
+ resolveUpdate = resolve;
+ }),
+ );
+ renderWithProviders(<OpsPage />);
+
+ const vipSwitch = (await screen.findAllByRole('switch'))[0];
+ await waitFor(() => expect(vipSwitch).toBeChecked());
+ fireEvent.click(vipSwitch);
+ await waitFor(() => expect(updateIsVIPChannel).toHaveBeenCalledTimes(1));
+ expect(vipSwitch).toBeDisabled();
+
+ fireEvent.click(vipSwitch);
+ expect(updateIsVIPChannel).toHaveBeenCalledTimes(1);
+
+ resolveUpdate();
+ await waitFor(() => expect(vipSwitch).toBeEnabled());
+ });
+
it('hides write controls for read-only users', async () => {
useAuthStore.setState({ token: 'token-reader', user: 'reader', admin:
false });
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 7476c0d3..f67552f9 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -16,7 +16,7 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { queryProxyHomePage } from '../../../api/proxy';
@@ -60,6 +60,14 @@ function renderPage() {
);
}
+const createDeferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
describe('ProxyPage', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -119,4 +127,29 @@ describe('ProxyPage', () => {
expect(screen.queryByText('5.3.0')).not.toBeInTheDocument();
expect(screen.getAllByText('N/A').length).toBeGreaterThanOrEqual(5);
});
+ it('keeps the latest Proxy list when an older refresh resolves last', async
() => {
+ const older = createDeferred<typeof proxyHome>();
+ const latest = createDeferred<typeof proxyHome>();
+ vi.mocked(queryProxyHomePage)
+ .mockResolvedValueOnce(proxyHome)
+ .mockReturnValueOnce(older.promise)
+ .mockReturnValueOnce(latest.promise);
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('127.0.0.1:8081');
+
+ const refresh = screen.getByRole('button', { name: '刷新' });
+ await user.click(refresh);
+ await user.click(refresh);
+ await act(async () => latest.resolve({
+ proxyAddrList: ['127.0.0.2:8081'],
+ currentProxyAddr: '127.0.0.2:8081',
+ }));
+ expect(await screen.findByText('127.0.0.2:8081')).toBeInTheDocument();
+
+ await act(async () => older.resolve(proxyHome));
+ expect(screen.getByText('127.0.0.2:8081')).toBeInTheDocument();
+ expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
+ });
+
});