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
commit 7a68836c14e2510ba9e65146d92b1773d7e0b5b5 Author: yyqdbngt <[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]> --- 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(); + }); + });
