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 ca32bc809 fix(web): consolidate interaction lifecycle fixes (#2851)
ca32bc809 is described below
commit ca32bc80902f1d51537c2fca8107663a9779fcba
Author: shown <[email protected]>
AuthorDate: Wed Sep 2 15:39:16 2026 +0800
fix(web): consolidate interaction lifecycle fixes (#2851)
* fix(message): preserve commas in user properties
Signed-off-by: yuluo-yx <[email protected]>
* fix(auth): deduplicate login submissions
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2711] fix(settings): display actual build metadata
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2729] fix(cluster): ignore stale modal request results
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2691] fix(message): reset query and trace lifecycle
Signed-off-by: yuluo-yx <[email protected]>
---------
Signed-off-by: yuluo-yx <[email protected]>
---
.../pages/cluster/__tests__/ClusterPage.test.tsx | 105 +++++++++++++++++++-
web/src/pages/cluster/index.tsx | 34 ++++---
.../__tests__/MessagePageAsyncState.test.tsx | 106 +++++++++++++++++++--
web/src/pages/instance/message.tsx | 78 +++++++++++----
web/src/pages/instance/topic.tsx | 2 +-
web/src/pages/login/index.test.tsx | 47 +++++++--
web/src/pages/login/index.tsx | 8 +-
web/src/pages/settings/AboutTab.tsx | 6 +-
web/src/pages/settings/__tests__/AboutTab.test.tsx | 51 ++++++++++
web/src/utils/messageProperties.test.ts | 22 +++++
web/src/utils/messageProperties.ts | 5 +-
11 files changed, 414 insertions(+), 50 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 91b354d1e..072294221 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -21,7 +21,11 @@ import userEvent from '@testing-library/user-event';
import type React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
-import type { ClusterInfo } from '../../../api/cluster';
+import type {
+ ClusterInfo,
+ ClusterProbeResult,
+ NameServerConfigDiffResult,
+} from '../../../api/cluster';
import { LangProvider } from '../../../i18n/LangContext';
const clusterServiceMocks = vi.hoisted(() => ({
@@ -629,6 +633,105 @@ describe('Cluster page', () => {
expect(within(dialog).getByText('正在检测 Broker 配置差异')).toBeInTheDocument();
});
+ it('does not reopen a closed NameServer config diff when its request
finishes', async () => {
+ const pendingDiff = deferred<NameServerConfigDiffResult>();
+ clusterServiceMocks.listRegistryClusters.mockResolvedValue([
+ {
+ ...buildCluster(),
+ name: 'rocketmq1',
+ nsClusterName: 'rocketmq1',
+ endpoint: 'rocketmq1-nameserver:9876',
+ nameServers: [{ addr: 'rocketmq1-nameserver:9876', status: 'healthy'
}],
+ },
+ ]);
+
clusterServiceMocks.getNameServerConfigDiff.mockReturnValue(pendingDiff.promise);
+ renderWithProviders(<ClusterPage />);
+
+ fireEvent.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
+ const row = await screen.findByRole('row', { name:
/rocketmq1-nameserver:9876/ });
+ fireEvent.click(within(row).getByRole('button', { name: /配置差异/ }));
+ const dialog = await screen.findByRole('dialog', { name: /NameServer 配置差异/
});
+ fireEvent.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+ await waitFor(() => expect(dialog).toHaveClass('ant-zoom-leave'));
+
+ await act(async () => {
+ pendingDiff.resolve({
+ cluster: 'rocketmq1',
+ complete: true,
+ driftDetected: false,
+ nodeCount: 1,
+ reachableNodeCount: 1,
+ comparedKeys: ['serverWorkerThreads'],
+ nodes: [{ address: 'rocketmq1-nameserver:9876', reachable: true }],
+ differences: [],
+ });
+ await pendingDiff.promise;
+ });
+
+ expect(dialog).toHaveClass('ant-zoom-leave');
+ });
+
+ it('keeps the latest connection result after closing and reopening the
modal', async () => {
+ const staleProbe = deferred<ClusterProbeResult>();
+ const latestProbe = deferred<ClusterProbeResult>();
+ clusterServiceMocks.testClusterConnection
+ .mockReturnValueOnce(staleProbe.promise)
+ .mockReturnValueOnce(latestProbe.promise);
+ renderWithProviders(<ClusterPage />);
+
+ const openModal = async () => {
+ fireEvent.click(await screen.findByRole('button', { name: /新建集群/ }));
+ return screen.findByRole('dialog', { name: /测试集群连接/ });
+ };
+ let dialog = await openModal();
+ fireEvent.change(within(dialog).getByLabelText(/NameServer 地址/), {
+ target: { value: 'stale-nameserver:9876' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: /测试连接/ }));
+ await waitFor(() =>
+ expect(clusterServiceMocks.testClusterConnection).toHaveBeenCalledWith(
+ 'stale-nameserver:9876',
+ ),
+ );
+ fireEvent.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+
+ dialog = await openModal();
+ fireEvent.change(within(dialog).getByLabelText(/NameServer 地址/), {
+ target: { value: 'latest-nameserver:9876' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: /测试连接/ }));
+ await waitFor(() =>
expect(clusterServiceMocks.testClusterConnection).toHaveBeenCalledTimes(2));
+
+ await act(async () => {
+ latestProbe.resolve({
+ connected: true,
+ namesrvAddr: 'latest-nameserver:9876',
+ clusterName: 'latest-cluster',
+ brokerCount: 1,
+ brokerNames: ['latest-broker'],
+ elapsedMillis: 10,
+ message: 'ok',
+ });
+ await latestProbe.promise;
+ });
+ expect(within(dialog).getByText('latest-cluster')).toBeInTheDocument();
+
+ await act(async () => {
+ staleProbe.resolve({
+ connected: true,
+ namesrvAddr: 'stale-nameserver:9876',
+ clusterName: 'stale-cluster',
+ brokerCount: 1,
+ brokerNames: ['stale-broker'],
+ elapsedMillis: 20,
+ message: 'ok',
+ });
+ await staleProbe.promise;
+ });
+ expect(within(dialog).getByText('latest-cluster')).toBeInTheDocument();
+
expect(within(dialog).queryByText('stale-cluster')).not.toBeInTheDocument();
+ });
+
it('polls the API after two seconds and renders only returned metrics',
async () => {
vi.useFakeTimers();
const randomSpy = vi.spyOn(Math, 'random');
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 6362ec5d5..ce8c9abe8 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -169,6 +169,8 @@ const ClusterPage = () => {
const nsRegistryRequestRef = useRef(0);
const registryClustersRequestRef = useRef(0);
const k8sCertsRequestRef = useRef(0);
+ const nsConfigDiffRequestRef = useRef(0);
+ const connectionTestRequestRef = useRef(0);
const loadRegistryClusters = useCallback(async () => {
const requestId = ++registryClustersRequestRef.current;
@@ -227,6 +229,8 @@ const ClusterPage = () => {
nsRegistryRequestRef.current += 1;
registryClustersRequestRef.current += 1;
k8sCertsRequestRef.current += 1;
+ nsConfigDiffRequestRef.current += 1;
+ connectionTestRequestRef.current += 1;
},
[],
);
@@ -328,6 +332,7 @@ const ClusterPage = () => {
const openNameServerConfigDiff = useCallback(
async (cluster: ClusterInfo) => {
+ const requestId = ++nsConfigDiffRequestRef.current;
setNsConfigDiffState({
open: true,
loading: true,
@@ -336,6 +341,7 @@ const ClusterPage = () => {
});
try {
const result = await getNameServerConfigDiff(cluster.id,
selectedInstanceIdRef.current);
+ if (requestId !== nsConfigDiffRequestRef.current) return;
setNsConfigDiffState({
open: true,
loading: false,
@@ -343,6 +349,7 @@ const ClusterPage = () => {
result,
});
} catch {
+ if (requestId !== nsConfigDiffRequestRef.current) return;
setNsConfigDiffState((current) => ({ ...current, loading: false }));
message.error(t('cluster.nsConfigDiffFailed'));
}
@@ -373,6 +380,10 @@ const ClusterPage = () => {
},
[t],
);
+ const closeNameServerConfigDiff = useCallback(() => {
+ nsConfigDiffRequestRef.current += 1;
+ setNsConfigDiffState({ open: false, loading: false, cluster: null, result:
null });
+ }, []);
// ─── Connection test ──────────────────────────────────────────────────────
const [connectModalOpen, setConnectModalOpen] = useState(false);
@@ -381,11 +392,14 @@ const ClusterPage = () => {
const [connectForm] = Form.useForm();
const openConnectModal = useCallback(() => {
+ connectionTestRequestRef.current += 1;
setProbeResult(null);
+ setConnectTesting(false);
setConnectModalOpen(true);
}, []);
const closeConnectModal = useCallback(() => {
+ connectionTestRequestRef.current += 1;
setConnectModalOpen(false);
setConnectTesting(false);
setProbeResult(null);
@@ -393,22 +407,26 @@ const ClusterPage = () => {
}, [connectForm]);
const handleTestConnection = useCallback(async () => {
+ const requestId = ++connectionTestRequestRef.current;
let namesrvAddr: string;
try {
({ namesrvAddr } = await connectForm.validateFields());
} catch {
return;
}
+ if (requestId !== connectionTestRequestRef.current) return;
setConnectTesting(true);
setProbeResult(null);
try {
const result = await testClusterConnection(namesrvAddr);
+ if (requestId !== connectionTestRequestRef.current) return;
setProbeResult(result);
message.success(t('cluster.testConnectionSuccess'));
} catch {
+ if (requestId !== connectionTestRequestRef.current) return;
message.error(t('cluster.testConnectionFailed'));
} finally {
- setConnectTesting(false);
+ if (requestId === connectionTestRequestRef.current)
setConnectTesting(false);
}
}, [connectForm, t]);
@@ -838,18 +856,8 @@ const ClusterPage = () => {
<Modal
title={t('cluster.nsConfigDiffTitle', { name: titleName })}
open={open}
- onCancel={() =>
- setNsConfigDiffState({ open: false, loading: false, cluster: null,
result: null })
- }
- footer={
- <Button
- onClick={() =>
- setNsConfigDiffState({ open: false, loading: false, cluster:
null, result: null })
- }
- >
- {t('common.close')}
- </Button>
- }
+ onCancel={closeNameServerConfigDiff}
+ footer={<Button
onClick={closeNameServerConfigDiff}>{t('common.close')}</Button>}
width={920}
destroyOnHidden
>
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 6bdcde04d..b557a4b3b 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -37,13 +37,17 @@ const instanceFilterMocks = vi.hoisted(() => ({
vi.mock('../../../services/messageService', () => ({
...serviceMocks,
queryMessagePage: ({ page = 1, pageSize = 50, ...params }: Record<string,
unknown>) =>
- Promise.resolve(serviceMocks.queryMessages(params)).then((items) => ({
- items,
- total: items.length,
- page,
- size: pageSize,
- resultMayBeTruncated: false,
- })),
+ Promise.resolve(serviceMocks.queryMessages(params)).then((result) =>
+ Array.isArray(result)
+ ? {
+ items: result,
+ total: result.length,
+ page,
+ size: pageSize,
+ resultMayBeTruncated: false,
+ }
+ : result,
+ ),
}));
vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
@@ -165,6 +169,66 @@ describe('MessagePage async request ownership', () => {
expect(screen.queryByText('late-after-reset')).not.toBeInTheDocument();
});
+ it('resets pagination and the truncated-result warning', async () => {
+ serviceMocks.queryMessages.mockResolvedValue({
+ items: [createMessage('message-on-page-two')],
+ total: 101,
+ page: 2,
+ size: 50,
+ resultMayBeTruncated: true,
+ });
+ const user = userEvent.setup();
+ renderPage();
+ await selectTopic(user);
+
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ expect(await screen.findByText('message-on-page-two')).toBeInTheDocument();
+ expect(screen.getByText('共 101 条消息')).toBeInTheDocument();
+ expect(screen.getByText(/查询结果达到服务端扫描上限/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /重置/ }));
+
+ expect(screen.queryByText('message-on-page-two')).not.toBeInTheDocument();
+ expect(screen.queryByText('共 101 条消息')).not.toBeInTheDocument();
+ expect(screen.queryByText(/查询结果达到服务端扫描上限/)).not.toBeInTheDocument();
+
expect(document.querySelector('.ant-pagination-item-active')).not.toBeInTheDocument();
+ });
+
+ it('clears query state and invalidates an in-flight request when the query
mode changes', async () => {
+ const lateQuery = createDeferred<MessageRecord[]>();
+ serviceMocks.queryMessages
+ .mockResolvedValueOnce({
+ items: [createMessage('topic-result')],
+ total: 101,
+ page: 2,
+ size: 50,
+ resultMayBeTruncated: true,
+ })
+ .mockReturnValueOnce(lateQuery.promise);
+ const user = userEvent.setup();
+ renderPage();
+ await selectTopic(user);
+
+ const queryButton = screen.getByRole('button', { name: /^search查询$/ });
+ await user.click(queryButton);
+ expect(await screen.findByText('topic-result')).toBeInTheDocument();
+ await user.click(queryButton);
+ await waitFor(() =>
expect(serviceMocks.queryMessages).toHaveBeenCalledTimes(2));
+
+ await user.click(screen.getByText('按 Message Key'));
+
+ expect(screen.queryByText('topic-result')).not.toBeInTheDocument();
+ expect(screen.queryByText('共 101 条消息')).not.toBeInTheDocument();
+ expect(screen.queryByText(/查询结果达到服务端扫描上限/)).not.toBeInTheDocument();
+
expect(document.querySelector('.ant-pagination-item-active')).not.toBeInTheDocument();
+ expect(document.querySelector('.ant-table-wrapper
.ant-spin-spinning')).not.toBeInTheDocument();
+
+ await act(async () => {
+ lateQuery.resolve([createMessage('late-topic-result')]);
+ });
+ expect(screen.queryByText('late-topic-result')).not.toBeInTheDocument();
+ });
+
it('clears query results and message details when the selected instance
changes', async () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-from-instance-a')]);
let currentInstanceId = 1;
@@ -231,6 +295,34 @@ describe('MessagePage async request ownership', () => {
).toBeInTheDocument();
});
+ it('loads a message trace lazily and reuses it for the same message', async
() => {
+ serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
+
serviceMocks.getMessageTrace.mockResolvedValue(createTrace('cached-trace'));
+ const user = userEvent.setup();
+ renderPage();
+ await selectTopic(user);
+
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const row = await screen.findByRole('row', { name: /message-a/ });
+ await user.click(within(row).getByRole('button', { name: /详情/ }));
+ let dialog = await screen.findByRole('dialog', { name: '消息详情' });
+ expect(serviceMocks.getMessageTrace).not.toHaveBeenCalled();
+
+ await user.click(within(dialog).getByText('消息轨迹'));
+ expect(await within(dialog).findByText('cached-trace
description')).toBeInTheDocument();
+ expect(serviceMocks.getMessageTrace).toHaveBeenCalledTimes(1);
+ await user.click(within(dialog).getByText('消息内容'));
+ await user.click(within(dialog).getByText('消息轨迹'));
+ expect(serviceMocks.getMessageTrace).toHaveBeenCalledTimes(1);
+
+ await user.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+ await user.click(within(row).getByRole('button', { name: /详情/ }));
+ dialog = await screen.findByRole('dialog', { name: '消息详情' });
+ await user.click(within(dialog).getByText('消息轨迹'));
+ expect(await within(dialog).findByText('cached-trace
description')).toBeInTheDocument();
+ expect(serviceMocks.getMessageTrace).toHaveBeenCalledTimes(1);
+ });
+
it('requiresGroupAndClientBeforeDirectConsumeTest', async () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
const user = userEvent.setup();
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 4a089f76d..dfd51b495 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -278,6 +278,7 @@ const MessagePageContent = ({
const [directConsumeSubmitting, setDirectConsumeSubmitting] =
useState(false);
const queryGenerationRef = useRef(0);
const traceGenerationRef = useRef(0);
+ const traceCacheRef = useRef(new Map<string, Promise<TraceRecord | null>>());
useEffect(
() => () => {
@@ -303,15 +304,29 @@ const MessagePageContent = ({
: queryValidationError;
/* ─── Handlers ─── */
+ const clearQueryResults = () => {
+ setMessages([]);
+ setMessageTotal(0);
+ setMessagePage(1);
+ setResultMayBeTruncated(false);
+ setQueryError(null);
+ setQueryLoading(false);
+ };
+
const handleReset = () => {
queryGenerationRef.current += 1;
setSelectedTopic(undefined);
setKeyInput('');
setMsgIdInput('');
setDateRange(getDefaultRange());
- setMessages([]);
- setQueryError(null);
- setQueryLoading(false);
+ clearQueryResults();
+ };
+
+ const handleQueryModeChange = (mode: QueryMode) => {
+ if (mode === queryMode) return;
+ queryGenerationRef.current += 1;
+ setQueryMode(mode);
+ clearQueryResults();
};
const executeQuery = async (
@@ -369,7 +384,7 @@ const MessagePageContent = ({
const replayHistoryRecord = async (record: MessageQueryHistory) => {
const modeMap: Record<string, QueryMode> = { TOPIC: 'topic', KEY: 'key',
MSG_ID: 'msgid' };
const mode = modeMap[record.queryType] || 'topic';
- setQueryMode(mode);
+ handleQueryModeChange(mode);
setSelectedTopic(record.topic);
setKeyInput(record.messageKey || '');
setMsgIdInput(record.msgId || '');
@@ -377,10 +392,13 @@ const MessagePageContent = ({
setDateRange([dayjs(record.startTime), dayjs(record.endTime)]);
}
setHistoryDrawerOpen(false);
+ const requestGeneration = queryGenerationRef.current + 1;
+ queryGenerationRef.current = requestGeneration;
setQueryLoading(true);
setQueryError(null);
try {
const results = await getMessageQueryResults(record.id);
+ if (queryGenerationRef.current !== requestGeneration) return;
const mapped: MessageRecord[] = results.map((r) => ({
msgId: r.msgId,
topic: r.topic,
@@ -402,14 +420,18 @@ const MessagePageContent = ({
setResultMayBeTruncated(false);
message.success(`已加载历史查询结果,共 ${mapped.length} 条`);
} catch (error) {
- setQueryError(getErrorMessage(error, '加载历史结果失败'));
+ if (queryGenerationRef.current === requestGeneration) {
+ setQueryError(getErrorMessage(error, '加载历史结果失败'));
+ }
} finally {
- setQueryLoading(false);
+ if (queryGenerationRef.current === requestGeneration) {
+ setQueryLoading(false);
+ }
}
};
const replayTraceRecord = (record: TraceQueryHistory) => {
- setQueryMode('msgid');
+ handleQueryModeChange('msgid');
setSelectedTopic(record.topic);
setMsgIdInput(record.msgId);
setHistoryDrawerOpen(false);
@@ -419,21 +441,27 @@ const MessagePageContent = ({
const handleVerifyConsume = () => {
message.warning('消费验证接口尚未接入,无法确认该消息的真实消费状态');
};
- const openDetail = async (record: MessageRecord, tab = 'content') => {
+ const loadMessageTrace = async (record: MessageRecord) => {
const requestGeneration = traceGenerationRef.current + 1;
traceGenerationRef.current = requestGeneration;
- setSelectedMsg(record);
- setModalTab(tab);
- setModalOpen(true);
setTraceData(null);
setTraceLoading(true);
setTraceError(null);
- if (tab === 'trace') {
- setTraceQueryMode('msgid');
- setTraceQueryValue(record.msgId);
+ setTraceQueryMode('msgid');
+ setTraceQueryValue(record.msgId);
+ const cacheKey = JSON.stringify([selectedInstanceId, record.topic,
record.msgId]);
+ let traceRequest = traceCacheRef.current.get(cacheKey);
+ if (!traceRequest) {
+ traceRequest = getMessageTrace(record.msgId, selectedInstanceId,
record.topic).catch(
+ (error) => {
+ traceCacheRef.current.delete(cacheKey);
+ throw error;
+ },
+ );
+ traceCacheRef.current.set(cacheKey, traceRequest);
}
try {
- const result = await getMessageTrace(record.msgId, selectedInstanceId,
record.topic);
+ const result = await traceRequest;
if (traceGenerationRef.current !== requestGeneration) return;
setTraceData(result);
setTraceError(null);
@@ -448,6 +476,22 @@ const MessagePageContent = ({
}
};
+ const openDetail = (record: MessageRecord, tab = 'content') => {
+ traceGenerationRef.current += 1;
+ setSelectedMsg(record);
+ setModalTab(tab);
+ setModalOpen(true);
+ setTraceData(null);
+ setTraceLoading(false);
+ setTraceError(null);
+ if (tab === 'trace') void loadMessageTrace(record);
+ };
+
+ const handleModalTabChange = (tab: string) => {
+ setModalTab(tab);
+ if (tab === 'trace' && selectedMsg) void loadMessageTrace(selectedMsg);
+ };
+
const runTraceQuery = async () => {
const requestGeneration = traceGenerationRef.current + 1;
traceGenerationRef.current = requestGeneration;
@@ -848,7 +892,7 @@ const MessagePageContent = ({
<Segmented
options={QUERY_OPTIONS}
value={queryMode}
- onChange={(v) => setQueryMode(v as QueryMode)}
+ onChange={(v) => handleQueryModeChange(v as QueryMode)}
/>
</Space>
@@ -1044,7 +1088,7 @@ const MessagePageContent = ({
</Flex>
}
>
- <Tabs activeKey={modalTab} onChange={setModalTab} items={modalTabs} />
+ <Tabs activeKey={modalTab} onChange={handleModalTabChange}
items={modalTabs} />
</Modal>
<Modal
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 437319059..c2860ddd0 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -1720,7 +1720,7 @@ const TopicPage = () => {
/>
{propsMode === 'text' && (
<Text type="secondary" style={{ fontSize: 14 }}>
- 支持 key=value,多个属性用换行或逗号分隔
+ 支持 key=value,每行填写一个属性;属性值可以包含逗号
</Text>
)}
</Flex>
diff --git a/web/src/pages/login/index.test.tsx
b/web/src/pages/login/index.test.tsx
index 7fc190dfd..ddad85aaa 100644
--- a/web/src/pages/login/index.test.tsx
+++ b/web/src/pages/login/index.test.tsx
@@ -60,6 +60,15 @@ describe('LoginPage', () => {
</MemoryRouter>,
);
+ const fillCredentials = () => {
+ fireEvent.change(screen.getByPlaceholderText('login.usernamePlaceholder'),
{
+ target: { value: 'alice' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('login.passwordPlaceholder'),
{
+ target: { value: 'secret' },
+ });
+ };
+
it('renders the brand and an accessible theme toggle', () => {
renderPage();
expect(screen.getByText('RocketMQ Studio')).toBeTruthy();
@@ -71,12 +80,7 @@ describe('LoginPage', () => {
user: { username: 'alice', userId: 42, admin: true },
});
renderPage();
- fireEvent.change(screen.getByPlaceholderText('login.usernamePlaceholder'),
{
- target: { value: 'alice' },
- });
- fireEvent.change(screen.getByPlaceholderText('login.passwordPlaceholder'),
{
- target: { value: 'secret' },
- });
+ fillCredentials();
fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
await waitFor(() => expect(loginStoreMock).toHaveBeenCalledWith('alice',
42, true));
@@ -84,6 +88,37 @@ describe('LoginPage', () => {
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
});
+ it('owns an in-flight login request synchronously and allows retry after
failure', async () => {
+ let rejectFirst: ((reason?: unknown) => void) | undefined;
+ loginApiMock
+ .mockReturnValueOnce(
+ new Promise((_, reject) => {
+ rejectFirst = reject;
+ }),
+ )
+ .mockResolvedValueOnce({
+ user: { username: 'alice', userId: 42, admin: true },
+ });
+ renderPage();
+ fillCredentials();
+
+ const form = document.querySelector('form');
+ expect(form).not.toBeNull();
+ fireEvent.submit(form!);
+ fireEvent.submit(form!);
+
+ await waitFor(() => expect(loginApiMock).toHaveBeenCalledTimes(1));
+ rejectFirst?.(new Error('temporary failure'));
+ const submitButton =
document.querySelector<HTMLButtonElement>('button[type="submit"]');
+ expect(submitButton).not.toBeNull();
+ await waitFor(() => expect(submitButton!.disabled).toBe(false));
+
+ fireEvent.submit(form!);
+
+ await waitFor(() => expect(loginApiMock).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(loginStoreMock).toHaveBeenCalledWith('alice',
42, true));
+ });
+
it('keeps required-field validation and does not call the API on empty
submit', async () => {
renderPage();
fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
index 92f61d76e..f75ebd402 100644
--- a/web/src/pages/login/index.tsx
+++ b/web/src/pages/login/index.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { useRef, useState } from 'react';
import {
LockOutlined,
MoonOutlined,
@@ -40,6 +40,7 @@ interface LoginFormValues {
const LoginPage = () => {
const [loading, setLoading] = useState(false);
+ const loginInFlightRef = useRef(false);
const [form] = Form.useForm<LoginFormValues>();
const { t } = useLang();
const { message } = App.useApp();
@@ -48,6 +49,10 @@ const LoginPage = () => {
const { darkMode, toggleTheme } = useTheme();
const onFinish = async (values: LoginFormValues) => {
+ // React state is applied on the next render, so it cannot prevent two
submit
+ // events in the same tick. Own the request synchronously before awaiting
it.
+ if (loginInFlightRef.current) return;
+ loginInFlightRef.current = true;
setLoading(true);
try {
const data = await loginApi(values.username, values.password);
@@ -58,6 +63,7 @@ const LoginPage = () => {
const errorMsg = err instanceof Error ? err.message : t('login.failed');
message.error(errorMsg);
} finally {
+ loginInFlightRef.current = false;
setLoading(false);
}
};
diff --git a/web/src/pages/settings/AboutTab.tsx
b/web/src/pages/settings/AboutTab.tsx
index 6e8faa1bb..f08ab7473 100644
--- a/web/src/pages/settings/AboutTab.tsx
+++ b/web/src/pages/settings/AboutTab.tsx
@@ -24,7 +24,8 @@ export const AboutTab = () => (
<div style={{ maxWidth: 800 }}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="版本">0.1.0</Descriptions.Item>
- <Descriptions.Item label="构建时间">2024-01-15 14:30:00</Descriptions.Item>
+ <Descriptions.Item label="构建提交">{__BUILD_COMMIT__}</Descriptions.Item>
+ <Descriptions.Item label="构建时间">{__BUILD_TIME__}</Descriptions.Item>
<Descriptions.Item label="RocketMQ 支持版本">4.x / 5.x</Descriptions.Item>
<Descriptions.Item label="前端框架">React 18 + Ant Design
5</Descriptions.Item>
<Descriptions.Item label="后端框架">Spring Boot 3 + RocketMQ MCP
Server</Descriptions.Item>
@@ -49,7 +50,8 @@ export const AboutTab = () => (
<Divider />
<Text type="secondary">
- Copyright © 2024 Apache Software Foundation. Licensed under the Apache
License, Version 2.0.
+ Copyright © {__BUILD_TIME__.slice(0, 4)} Apache Software Foundation.
Licensed under the Apache
+ License, Version 2.0.
</Text>
</div>
);
diff --git a/web/src/pages/settings/__tests__/AboutTab.test.tsx
b/web/src/pages/settings/__tests__/AboutTab.test.tsx
new file mode 100644
index 000000000..0927baa35
--- /dev/null
+++ b/web/src/pages/settings/__tests__/AboutTab.test.tsx
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render, screen } from '@testing-library/react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { AboutTab } from '../AboutTab';
+
+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(),
+ })),
+ });
+});
+
+describe('AboutTab', () => {
+ it('renders the injected build metadata and build-year copyright', () => {
+ render(<AboutTab />);
+
+ expect(screen.getByText(__BUILD_COMMIT__)).toBeInTheDocument();
+ expect(screen.getByText(__BUILD_TIME__)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ `Copyright © ${__BUILD_TIME__.slice(0, 4)} Apache Software Foundation.
Licensed under the Apache License, Version 2.0.`,
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('2024-01-15 14:30:00')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/utils/messageProperties.test.ts
b/web/src/utils/messageProperties.test.ts
index 95a2c4439..7be1fac51 100644
--- a/web/src/utils/messageProperties.test.ts
+++ b/web/src/utils/messageProperties.test.ts
@@ -19,6 +19,28 @@ import { describe, expect, it } from 'vitest';
import { parseMessageProperties } from './messageProperties';
describe('parseMessageProperties', () => {
+ it('preserves commas and additional equals signs in property values', () => {
+ const result = parseMessageProperties(
+ 'location=Beijing,China\nsignature=part-a=part-b\ntags=blue,green',
+ );
+
+ expect(result).toEqual({
+ properties: {
+ location: 'Beijing,China',
+ signature: 'part-a=part-b',
+ tags: 'blue,green',
+ },
+ errors: [],
+ });
+ });
+
+ it('supports Windows line endings and reports duplicate keys', () => {
+ const result =
parseMessageProperties('traceId=first\r\ntenant=demo\r\ntraceId=second');
+
+ expect(result.properties).toEqual({ traceId: 'first', tenant: 'demo' });
+ expect(result.errors).toEqual(['属性名“traceId”重复']);
+ });
+
it('preserves JavaScript object prototype property names', () => {
const result = parseMessageProperties(
'__proto__=trace-prototype\nconstructor=trace-constructor\ntoString=trace-string',
diff --git a/web/src/utils/messageProperties.ts
b/web/src/utils/messageProperties.ts
index 41641a4e1..cec11a6f9 100644
--- a/web/src/utils/messageProperties.ts
+++ b/web/src/utils/messageProperties.ts
@@ -20,11 +20,12 @@ interface ParsedProperties {
errors: string[];
}
-// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
+// 解析批量粘贴的用户属性串:每行一个 key=value,等号只取第一个。
+// 逗号属于合法属性值,不能同时作为记录分隔符,否则会破坏地址、列表等常见值。
export const parseMessageProperties = (text: string): ParsedProperties => {
const entries = new Map<string, string>();
const errors: string[] = [];
- for (const line of text.split(/[\n,]+/)) {
+ for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const eqIndex = trimmed.indexOf('=');