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 579918565 fix(web): three console pages kept acting on stale data
after a failed load (#5032)
579918565 is described below
commit 5799185652729ae1cb49e66b010e5b1ec1c4d5d9
Author: zmuxuny <[email protected]>
AuthorDate: Fri Oct 9 23:37:18 2026 -0700
fix(web): three console pages kept acting on stale data after a failed load
(#5032)
Three frontend defects where a failed or in-flight load left the page
acting on data it no longer had.
- The broker-config dialog stayed open with its old preview after a PARTIAL
apply, and the preview
button was not disabled while a write was in flight; both now follow the
`nsSubmittingRef`
convention already used in the same file. (#5032)
- The cloud-credential tab kept the previous rows after a load failure, so
their action buttons could
still rotate or delete a credential that was no longer listed. (#5253)
- The proxy page wrote a stale node list and address back after a failed
health probe, because the
ownership check was bypassed by the `catch`. (#5256)
`ClusterPage` + `CloudCredentialTab` + `Proxy` suites: 67 tests green;
prettier clean.
Folded in #5253 and #5256 (same author).
---
.../pages/cluster/__tests__/ClusterPage.test.tsx | 38 ++++++++++
web/src/pages/cluster/index.tsx | 5 ++
web/src/pages/settings/CloudCredentialTab.tsx | 43 +++++++++--
.../settings/__tests__/CloudCredentialTab.test.tsx | 85 +++++++++++++++++++++-
web/src/pages/studio/Proxy.tsx | 3 +-
web/src/pages/studio/__tests__/Proxy.test.tsx | 36 ++++++++-
6 files changed, 199 insertions(+), 11 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 1eb02f723..3d57348bb 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -598,6 +598,44 @@ describe('Cluster page', () => {
expect(within(dialog).queryByText('defaultTopicQueueNums=16')).not.toBeInTheDocument();
});
+ it('discards broker config previews after a partial update changes the
cluster', async () => {
+ const latePreview = deferred<ClusterConfigPreviewResult>();
+ clusterServiceMocks.updateClusterConfig.mockResolvedValue({
+ cluster: buildCluster(),
+ status: 'PARTIAL',
+ successfulBrokers: ['10.101.2.11:10911'],
+ failedBrokers: [{ address: '10.101.2.12:10911', message: 'timeout' }],
+ });
+ renderWithProviders(<ClusterPage />);
+
+ const brokerRow = await screen.findByRole('row', { name:
/10\.101\.2\.11:10911/ });
+ fireEvent.click(within(brokerRow).getByRole('button', { name: /^配\s*置$/
}));
+ const dialog = await screen.findByRole('dialog', { name: /配置 -
rocketmq-prod/ });
+ fireEvent.click(within(dialog).getByRole('button', { name: /预\s*览/ }));
+ expect(await
within(dialog).findByText('defaultTopicQueueNums=8')).toBeInTheDocument();
+
+
clusterServiceMocks.previewClusterConfig.mockReturnValueOnce(latePreview.promise);
+ fireEvent.click(within(dialog).getByRole('button', { name: /预\s*览/ }));
+ await waitFor(() =>
expect(clusterServiceMocks.previewClusterConfig).toHaveBeenCalledTimes(2));
+ fireEvent.click(within(dialog).getByRole('button', { name: /^ok$/i }));
+ expect(await screen.findByText(/部分 Broker 配置已更新/)).toBeInTheDocument();
+
expect(within(dialog).queryByText('defaultTopicQueueNums=8')).not.toBeInTheDocument();
+
+ await act(async () => {
+ latePreview.resolve({
+ cluster: buildCluster(),
+ currentConfig: buildCluster().config!,
+ proposedConfig: { ...buildCluster().config!, writeQueueNums: 24 },
+ targetBrokers: [{ name: 'rocketmq-prod-0', address:
'10.101.2.11:10911' }],
+ brokerProperties: { defaultTopicQueueNums: '24' },
+ changes: [],
+ changed: true,
+ });
+ await latePreview.promise;
+ });
+
expect(within(dialog).queryByText('defaultTopicQueueNums=24')).not.toBeInTheDocument();
+ });
+
it('keeps cluster tabs usable when address fields are missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const submitSearch = async (placeholder: string, value: string) => {
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 86fd9b36d..cf33221c9 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -763,6 +763,11 @@ const ClusterPage = () => {
};
const handleConfigSubmit = async () => {
+ // A write can change only some Brokers. Any preview from before the write,
+ // including one still in flight, no longer describes the cluster
afterward.
+ configPreviewRequest.invalidate();
+ setConfigPreview(null);
+ setConfigPreviewLoading(false);
let values: ClusterConfigFormValues;
try {
values = await configForm.validateFields();
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx
b/web/src/pages/settings/CloudCredentialTab.tsx
index f4482bde5..d8627f764 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -17,6 +17,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
+ Alert,
Button,
Input,
Descriptions,
@@ -71,6 +72,8 @@ export const CloudCredentialTab = () => {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [loading, setLoading] = useState(true);
+ const [loadFailed, setLoadFailed] = useState(false);
+ const [loadedQueryKey, setLoadedQueryKey] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingCredential, setEditingCredential] = useState<CloudCredential |
null>(null);
const [form] = Form.useForm<CredentialFormValues>();
@@ -78,6 +81,7 @@ export const CloudCredentialTab = () => {
const [exporting, setExporting] = useState(false);
const requestSeqRef = useRef(0);
const submitInFlightRef = useRef(false);
+ const queryKey = JSON.stringify([vendorFilter, debouncedSearch, page,
pageSize]);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()),
300);
@@ -89,6 +93,7 @@ export const CloudCredentialTab = () => {
Promise.resolve().then(() => {
if (requestId === requestSeqRef.current) {
setLoading(true);
+ setLoadFailed(false);
}
});
return (async () => {
@@ -104,9 +109,13 @@ export const CloudCredentialTab = () => {
}
setCredentials(result.items);
setTotal(result.total);
+ setLoadedQueryKey(queryKey);
} catch {
if (requestId === requestSeqRef.current) {
- message.error(t('settings.credentialLoadFailed'));
+ setCredentials([]);
+ setTotal(0);
+ setLoadedQueryKey(null);
+ setLoadFailed(true);
}
} finally {
if (requestId === requestSeqRef.current) {
@@ -114,7 +123,7 @@ export const CloudCredentialTab = () => {
}
}
})();
- }, [debouncedSearch, page, pageSize, t, vendorFilter]);
+ }, [debouncedSearch, page, pageSize, queryKey, vendorFilter]);
useEffect(() => {
void loadCredentials();
@@ -169,6 +178,8 @@ export const CloudCredentialTab = () => {
};
const openEditModal = (credential: CloudCredential) => {
+ if (loading || loadFailed || search.trim() !== debouncedSearch ||
loadedQueryKey !== queryKey)
+ return;
setEditingCredential(credential);
form.setFieldsValue({
name: credential.name,
@@ -218,6 +229,8 @@ export const CloudCredentialTab = () => {
};
const handleDelete = async (credential: CloudCredential) => {
+ if (loading || loadFailed || search.trim() !== debouncedSearch ||
loadedQueryKey !== queryKey)
+ return;
try {
await deleteCloudCredential(credential.id);
const remainingOnPage = credentials.length - 1;
@@ -232,6 +245,9 @@ export const CloudCredentialTab = () => {
}
};
+ const filtersPending = search.trim() !== debouncedSearch;
+ const queryMismatch = loadedQueryKey !== queryKey;
+ const staleActionsDisabled = loading || loadFailed || filtersPending ||
queryMismatch;
const columns: ColumnsType<CloudCredential> = [
{ title: t('common.name'), dataIndex: 'name', key: 'name' },
{
@@ -260,6 +276,7 @@ export const CloudCredentialTab = () => {
type="link"
size="small"
icon={<EditOutlined />}
+ disabled={staleActionsDisabled}
onClick={() => openEditModal(record)}
>
{t('common.edit')}
@@ -270,7 +287,13 @@ export const CloudCredentialTab = () => {
okText={t('settings.confirmAction')}
cancelText={t('common.cancel')}
>
- <Button type="link" size="small" danger icon={<DeleteOutlined />}>
+ <Button
+ type="link"
+ size="small"
+ danger
+ icon={<DeleteOutlined />}
+ disabled={staleActionsDisabled}
+ >
{t('common.delete')}
</Button>
</Popconfirm>
@@ -315,18 +338,28 @@ export const CloudCredentialTab = () => {
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
- disabled={loading}
+ disabled={loading || filtersPending || (queryMismatch &&
!loadFailed)}
>
{t('settings.addCredential')}
</Button>
</Flex>
</Flex>
+ {loadFailed && (
+ <Alert
+ type="error"
+ showIcon
+ message={t('settings.credentialLoadFailed')}
+ action={<Button onClick={() => void
loadCredentials()}>{t('common.retry')}</Button>}
+ style={{ marginBottom: 16 }}
+ />
+ )}
+
<Table<CloudCredential>
columns={columns}
dataSource={credentials}
rowKey="id"
- loading={loading}
+ loading={loading || filtersPending || (queryMismatch && !loadFailed)}
pagination={{
current: page,
pageSize,
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
index ae4e045be..6c847a431 100644
--- a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -16,7 +16,7 @@
*/
import { beforeAll, beforeEach, 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 userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { downloadBlob } from '../../../utils/download';
@@ -62,10 +62,12 @@ const credentials: CloudCredentialPage = {
const deferred = <T,>() => {
let resolve!: (value: T) => void;
- const promise = new Promise<T>((promiseResolve) => {
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
+ reject = promiseReject;
});
- return { promise, resolve };
+ return { promise, resolve, reject };
};
const renderTab = () =>
@@ -171,6 +173,83 @@ describe('CloudCredentialTab', () => {
expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
});
+ it('hides stale credentials after a filtered reload fails and retries the
current filter', async () => {
+ const filteredLoad = deferred<CloudCredentialPage>();
+ const tencentPage: CloudCredentialPage = {
+ items: [
+ {
+ id: 9,
+ name: 'tencent-prod',
+ vendor: 'TENCENT',
+ accessKey: 'AKID****9999',
+ gmtCreate: '2026-08-18T12:00:00',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ };
+ vi.mocked(listCloudCredentials)
+ .mockResolvedValueOnce(credentials)
+ .mockReturnValueOnce(filteredLoad.promise)
+ .mockResolvedValueOnce(tencentPage);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderTab();
+
+ await screen.findByText('aliyun-test');
+ await user.click(screen.getAllByRole('combobox')[0]);
+ await user.click(
+ await screen.findByText('腾讯云', { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+ expect(listCloudCredentials).toHaveBeenLastCalledWith('TENCENT', '', 1,
20),
+ );
+ expect(screen.getByRole('button', { name: /编辑/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /删除/ })).toBeDisabled();
+
+ await act(async () => filteredLoad.reject(new Error('credential service
unavailable')));
+ const error = await screen.findByText('云凭据加载失败,请稍后重试');
+ expect(error.closest('.ant-alert')).not.toBeNull();
+ expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /删除/
})).not.toBeInTheDocument();
+
expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /^重\s*试$/ }));
+ expect(await screen.findByText('tencent-prod')).toBeInTheDocument();
+ expect(listCloudCredentials).toHaveBeenLastCalledWith('TENCENT', '', 1,
20);
+ expect(screen.queryByText('云凭据加载失败,请稍后重试')).not.toBeInTheDocument();
+ });
+
+ it('keeps loaded credentials usable when search whitespace does not change
the query', async () => {
+ renderTab();
+ await screen.findByText('aliyun-test');
+ await waitFor(() => expect(screen.getByRole('button', { name: /编辑/
})).toBeEnabled());
+
+ fireEvent.change(screen.getByPlaceholderText('搜索凭据名称'), { target: { value:
' ' } });
+
+ expect(screen.getByRole('button', { name: /编辑/ })).toBeEnabled();
+ expect(screen.getByRole('button', { name: /删除/ })).toBeEnabled();
+ });
+
+ it('reports an initial credential load failure instead of an empty
inventory', async () => {
+ vi.mocked(listCloudCredentials)
+ .mockRejectedValueOnce(new Error('credential service unavailable'))
+ .mockResolvedValueOnce({ items: [], total: 0, page: 1, size: 20 });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderTab();
+
+ const error = await screen.findByText('云凭据加载失败,请稍后重试');
+ expect(error.closest('.ant-alert')).not.toBeNull();
+ expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
+
expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /^重\s*试$/ }));
+ await waitFor(() => expect(listCloudCredentials).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
+ expect(screen.queryByText('云凭据加载失败,请稍后重试')).not.toBeInTheDocument(),
+ );
+ });
+
it('creates a credential from the modal form', async () => {
vi.mocked(createCloudCredential).mockResolvedValue({
id: 2,
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 29a6761aa..3a0cc7593 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -115,7 +115,6 @@ const ProxyPage: React.FC = () => {
// Overlay the live TCP health view (UP/PARTIAL/DOWN) when the backend
exposes it.
try {
const topology = await getProxyTopology();
- if (requestId !== loadRequestId.current) return false;
const statusByAddr = new Map(topology.map((node) => [node.proxyAddr,
node.status]));
nodes = baseNodes.map((node) => {
const probeStatus = statusByAddr.get(node.address);
@@ -132,6 +131,8 @@ const ProxyPage: React.FC = () => {
} catch {
// Health probing is best-effort; keep the unknown status when it is
unavailable.
}
+ // The probe can fail after a newer list (including a mutation result)
has committed.
+ if (requestId !== loadRequestId.current) return false;
setProxyNodes(nodes);
setClusterStats({
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 188d58ea8..081840d35 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -70,10 +70,12 @@ function renderPage() {
const createDeferred = <T,>() => {
let resolve!: (value: T) => void;
- const promise = new Promise<T>((resolvePromise) => {
+ let reject!: (reason: Error) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
+ reject = rejectPromise;
});
- return { promise, resolve };
+ return { promise, resolve, reject };
};
describe('ProxyPage', () => {
@@ -341,4 +343,34 @@ describe('ProxyPage', () => {
expect(screen.getByText('127.0.0.2:8081')).toBeInTheDocument();
expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
});
+
+ it('keeps the latest Proxy list when an older health probe fails last',
async () => {
+ const olderProbe = createDeferred<Awaited<ReturnType<typeof
getProxyTopology>>>();
+ const latestHome = {
+ proxyAddrList: ['127.0.0.2:8081'],
+ currentProxyAddr: '127.0.0.2:8081',
+ };
+ vi.mocked(queryProxyHomePage)
+ .mockResolvedValueOnce(proxyHome)
+ .mockResolvedValueOnce(proxyHome)
+ .mockResolvedValueOnce(latestHome);
+ vi.mocked(getProxyTopology)
+ .mockResolvedValueOnce([])
+ .mockReturnValueOnce(olderProbe.promise)
+ .mockResolvedValueOnce([]);
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('127.0.0.1:8081');
+
+ const refresh = screen.getByRole('button', { name: '刷新' });
+ await user.click(refresh);
+ await waitFor(() => expect(getProxyTopology).toHaveBeenCalledTimes(2));
+ await user.click(refresh);
+ expect(await screen.findByText('127.0.0.2:8081')).toBeInTheDocument();
+
+ await act(async () => olderProbe.reject(new Error('health probe
unavailable')));
+ expect(screen.getByText('127.0.0.2:8081')).toBeInTheDocument();
+ expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
+ expect(localStorage.getItem('proxyAddr')).toBe('127.0.0.2:8081');
+ });
});