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 3a18bab0 [ISSUE #1505] Ignore stale cloud catalog responses (#1506)
3a18bab0 is described below
commit 3a18bab02fbea1afd9c84d5cf923dc6d57218047
Author: youngkermit8-coder <[email protected]>
AuthorDate: Tue Aug 11 20:19:02 2026 +0800
[ISSUE #1505] Ignore stale cloud catalog responses (#1506)
* [ISSUE #1505] Ignore stale cloud catalog responses
Signed-off-by: youngkermit8-coder <[email protected]>
* [ISSUE #1505] Clear cloud loading state on vendor changes
Signed-off-by: youngkermit8-coder <[email protected]>
---------
Signed-off-by: youngkermit8-coder <[email protected]>
---
.../pages/instance/__tests__/InstancePage.test.tsx | 316 +++++++++++++++++++++
web/src/pages/instance/index.tsx | 95 ++++++-
2 files changed, 396 insertions(+), 15 deletions(-)
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 5e8daeb3..2fd2442b 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -20,11 +20,27 @@ import { act, fireEvent, render, screen, waitFor, within }
from '@testing-librar
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as aliyunCatalogApi from '../../../api/aliyunCatalog';
+import * as cloudCredentialApi from '../../../api/cloudCredential';
import type { Instance } from '../../../api/instance';
import { LangProvider } from '../../../i18n/LangContext';
import * as instanceService from '../../../services/instanceService';
import InstancePage from '../index';
+vi.mock('../../../api/aliyunCatalog', () => ({
+ listAliyunInstances: vi.fn(),
+ listAliyunRegions: vi.fn(),
+}));
+
+vi.mock('../../../api/cloudCredential', () => ({
+ listCloudCredentials: vi.fn(),
+}));
+
+vi.mock('../../../api/tencentCatalog', () => ({
+ listTencentInstances: vi.fn(),
+ listTencentRegions: vi.fn(),
+}));
+
vi.mock('../../../services/instanceService', () => ({
createInstance: vi.fn(),
deleteInstance: vi.fn(),
@@ -76,9 +92,22 @@ const renderPage = () =>
</App>,
);
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, reject, resolve };
+};
+
describe('InstancePage', () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([]);
+ vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([]);
+ vi.mocked(aliyunCatalogApi.listAliyunInstances).mockResolvedValue([]);
vi.mocked(instanceService.listInstances).mockResolvedValue([
instance('proxy-1', 'production-proxy'),
instance('direct-1', 'development-direct', 'DIRECT'),
@@ -211,6 +240,293 @@ describe('InstancePage', () => {
expect(within(dialog).getByText(/云凭据与云上实例完成接入/)).toBeInTheDocument();
});
+ it('ignores a stale region response after the cloud credential changes',
async () => {
+ const user = userEvent.setup();
+ const oldRegions = deferred<Array<{ regionId: string; regionName: string
}>>();
+ const latestRegions = deferred<Array<{ regionId: string; regionName:
string }>>();
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
+ {
+ id: 'cred-old',
+ name: 'old-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-old',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 'cred-latest',
+ name: 'latest-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-latest',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ vi.mocked(aliyunCatalogApi.listAliyunRegions)
+ .mockReturnValueOnce(oldRegions.promise)
+ .mockReturnValueOnce(latestRegions.promise);
+
+ renderPage();
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('tab', { name: /Aliyun 版/ }));
+ await waitFor(() =>
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalled());
+
+ const credentialSelect = within(dialog).getAllByRole('combobox')[0];
+ fireEvent.mouseDown(credentialSelect.parentElement!);
+ await user.click(
+ await screen.findByText(/old-account/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+
expect(aliyunCatalogApi.listAliyunRegions).toHaveBeenCalledWith('cred-old'),
+ );
+
+ fireEvent.mouseDown(credentialSelect.parentElement!);
+ await user.click(
+ await screen.findByText(/latest-account/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+
expect(aliyunCatalogApi.listAliyunRegions).toHaveBeenCalledWith('cred-latest'),
+ );
+
+ await act(async () =>
+ latestRegions.resolve([{ regionId: 'cn-shanghai', regionName: 'Shanghai'
}]),
+ );
+ await act(async () =>
+ oldRegions.resolve([{ regionId: 'cn-hangzhou-old', regionName: 'Old
Hangzhou' }]),
+ );
+
+ const regionSelect = within(dialog).getAllByRole('combobox')[1];
+ fireEvent.mouseDown(regionSelect.parentElement!);
+ expect(
+ await screen.findByText(/Shanghai/, { selector:
'.ant-select-item-option-content' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(/Old Hangzhou/, { selector:
'.ant-select-item-option-content' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('ignores a stale instance response after the cloud region changes', async
() => {
+ const user = userEvent.setup();
+ const oldInstances =
+ deferred<
+ Array<{ instanceId: string; instanceName: string; status: string;
regionId: string }>
+ >();
+ const latestInstances =
+ deferred<
+ Array<{ instanceId: string; instanceName: string; status: string;
regionId: string }>
+ >();
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
+ {
+ id: 'cred-1',
+ name: 'cloud-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-one',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([
+ { regionId: 'cn-beijing', regionName: 'Beijing' },
+ { regionId: 'cn-shanghai', regionName: 'Shanghai' },
+ ]);
+ vi.mocked(aliyunCatalogApi.listAliyunInstances)
+ .mockReturnValueOnce(oldInstances.promise)
+ .mockReturnValueOnce(latestInstances.promise);
+
+ renderPage();
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('tab', { name: /Aliyun 版/ }));
+ await waitFor(() =>
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalled());
+
+ const selects = within(dialog).getAllByRole('combobox');
+ fireEvent.mouseDown(selects[0].parentElement!);
+ await user.click(
+ await screen.findByText(/cloud-account/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
expect(aliyunCatalogApi.listAliyunRegions).toHaveBeenCalledWith('cred-1'));
+
+ fireEvent.mouseDown(selects[1].parentElement!);
+ await user.click(
+ await screen.findByText(/Beijing/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+
expect(aliyunCatalogApi.listAliyunInstances).toHaveBeenCalledWith('cred-1',
'cn-beijing'),
+ );
+
+ fireEvent.mouseDown(selects[1].parentElement!);
+ await user.click(
+ await screen.findByText(/Shanghai/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+
expect(aliyunCatalogApi.listAliyunInstances).toHaveBeenCalledWith('cred-1',
'cn-shanghai'),
+ );
+
+ await act(async () =>
+ latestInstances.resolve([
+ {
+ instanceId: 'rmq-latest',
+ instanceName: 'latest-instance',
+ status: 'RUNNING',
+ regionId: 'cn-shanghai',
+ },
+ ]),
+ );
+ await act(async () =>
+ oldInstances.resolve([
+ {
+ instanceId: 'rmq-old',
+ instanceName: 'old-instance',
+ status: 'RUNNING',
+ regionId: 'cn-beijing',
+ },
+ ]),
+ );
+
+ fireEvent.mouseDown(selects[2].parentElement!);
+ expect(
+ await screen.findByText(/latest-instance/, { selector:
'.ant-select-item-option-content' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(/old-instance/, { selector:
'.ant-select-item-option-content' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('clears a pending region load when the cloud vendor changes', async () =>
{
+ const user = userEvent.setup();
+ const pendingRegions = deferred<Array<{ regionId: string; regionName:
string }>>();
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
+ {
+ id: 'cred-aliyun',
+ name: 'aliyun-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-one',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 'cred-tencent',
+ name: 'tencent-account',
+ vendor: 'TENCENT',
+ accessKey: 'AKID-one',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
+
vi.mocked(aliyunCatalogApi.listAliyunRegions).mockReturnValue(pendingRegions.promise);
+
+ renderPage();
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('tab', { name: /Aliyun/ }));
+ await waitFor(() =>
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalled());
+
+ const credentialSelect = within(dialog).getAllByRole('combobox')[0];
+ fireEvent.mouseDown(credentialSelect.parentElement!);
+ await user.click(
+ await screen.findByText(/aliyun-account/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+
expect(aliyunCatalogApi.listAliyunRegions).toHaveBeenCalledWith('cred-aliyun'),
+ );
+ await waitFor(() =>
+
expect(within(dialog).getAllByRole('combobox')[1].closest('.ant-select')).toHaveClass(
+ 'ant-select-loading',
+ ),
+ );
+
+ await user.click(within(dialog).getByRole('tab', { name: /Tencent/ }));
+
+ const switchedSelects = within(dialog).getAllByRole('combobox');
+ expect(switchedSelects[0]).toHaveValue('');
+ expect(switchedSelects[1]).toHaveValue('');
+ expect(switchedSelects[2]).toHaveValue('');
+ expect(switchedSelects[1]).toBeDisabled();
+ expect(switchedSelects[2]).toBeDisabled();
+
expect(switchedSelects[1].closest('.ant-select')).not.toHaveClass('ant-select-loading');
+
+ await act(async () =>
+ pendingRegions.resolve([{ regionId: 'cn-hangzhou-old', regionName: 'Old
Hangzhou' }]),
+ );
+ expect(switchedSelects[1]).toBeDisabled();
+ });
+
+ it('clears a pending instance load when the cloud vendor changes', async ()
=> {
+ const user = userEvent.setup();
+ const pendingInstances =
+ deferred<
+ Array<{ instanceId: string; instanceName: string; status: string;
regionId: string }>
+ >();
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
+ {
+ id: 'cred-aliyun',
+ name: 'aliyun-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-one',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 'cred-tencent',
+ name: 'tencent-account',
+ vendor: 'TENCENT',
+ accessKey: 'AKID-one',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([
+ { regionId: 'cn-beijing', regionName: 'Beijing' },
+ ]);
+
vi.mocked(aliyunCatalogApi.listAliyunInstances).mockReturnValue(pendingInstances.promise);
+
+ renderPage();
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('tab', { name: /Aliyun/ }));
+ await waitFor(() =>
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalled());
+
+ const selects = within(dialog).getAllByRole('combobox');
+ fireEvent.mouseDown(selects[0].parentElement!);
+ await user.click(
+ await screen.findByText(/aliyun-account/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
expect(aliyunCatalogApi.listAliyunRegions).toHaveBeenCalled());
+ fireEvent.mouseDown(selects[1].parentElement!);
+ await user.click(
+ await screen.findByText(/Beijing/, { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+ expect(aliyunCatalogApi.listAliyunInstances).toHaveBeenCalledWith(
+ 'cred-aliyun',
+ 'cn-beijing',
+ ),
+ );
+ await waitFor(() =>
+
expect(selects[2].closest('.ant-select')).toHaveClass('ant-select-loading'),
+ );
+
+ await user.click(within(dialog).getByRole('tab', { name: /Tencent/ }));
+
+ const switchedSelects = within(dialog).getAllByRole('combobox');
+ expect(switchedSelects[0]).toHaveValue('');
+ expect(switchedSelects[1]).toHaveValue('');
+ expect(switchedSelects[2]).toHaveValue('');
+ expect(switchedSelects[1]).toBeDisabled();
+ expect(switchedSelects[2]).toBeDisabled();
+
expect(switchedSelects[2].closest('.ant-select')).not.toHaveClass('ant-select-loading');
+
+ await act(async () =>
+ pendingInstances.resolve([
+ {
+ instanceId: 'rmq-old',
+ instanceName: 'old-instance',
+ status: 'RUNNING',
+ regionId: 'cn-beijing',
+ },
+ ]),
+ );
+ expect(switchedSelects[2]).toBeDisabled();
+ });
+
it('sorts and renders instances without remarks', async () => {
const user = userEvent.setup();
vi.mocked(instanceService.listInstances).mockResolvedValue([
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 824d9297..4eb346cd 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -141,22 +141,41 @@ const InstancePage = () => {
useEffect(() => {
if (!cloudVendor || !addModalOpen) {
- return;
+ const timer = window.setTimeout(() => setCredentialsLoading(false), 0);
+ return () => window.clearTimeout(timer);
}
+ let active = true;
const timer = window.setTimeout(() => {
setCredentialsLoading(true);
listCloudCredentials()
- .then((items) => setCredentials(items.filter((item) => item.vendor ===
vendor)))
- .catch(() => message.error('云凭据列表加载失败'))
- .finally(() => setCredentialsLoading(false));
+ .then((items) => {
+ if (active) {
+ setCredentials(items.filter((item) => item.vendor === vendor));
+ }
+ })
+ .catch(() => {
+ if (active) {
+ message.error('云凭据列表加载失败');
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setCredentialsLoading(false);
+ }
+ });
}, 0);
- return () => window.clearTimeout(timer);
+ return () => {
+ active = false;
+ window.clearTimeout(timer);
+ };
}, [vendor, cloudVendor, addModalOpen]);
useEffect(() => {
if (!cloudVendor || !addCredentialId) {
- return;
+ const timer = window.setTimeout(() => setRegionsLoading(false), 0);
+ return () => window.clearTimeout(timer);
}
+ let active = true;
const timer = window.setTimeout(() => {
setRegionsLoading(true);
const request =
@@ -165,6 +184,9 @@ const InstancePage = () => {
: listTencentRegions(addCredentialId);
request
.then((items) => {
+ if (!active) {
+ return;
+ }
setRegions(items);
if (!addForm.getFieldValue('regionId')) {
const preferred = items.find(
@@ -175,16 +197,29 @@ const InstancePage = () => {
}
}
})
- .catch(() => message.error('云地域列表加载失败'))
- .finally(() => setRegionsLoading(false));
+ .catch(() => {
+ if (active) {
+ message.error('云地域列表加载失败');
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setRegionsLoading(false);
+ }
+ });
}, 0);
- return () => window.clearTimeout(timer);
+ return () => {
+ active = false;
+ window.clearTimeout(timer);
+ };
}, [vendor, cloudVendor, addCredentialId, addForm]);
useEffect(() => {
if (!cloudVendor || !addCredentialId || !addRegionId) {
- return;
+ const timer = window.setTimeout(() => setCloudInstancesLoading(false),
0);
+ return () => window.clearTimeout(timer);
}
+ let active = true;
const timer = window.setTimeout(() => {
setCloudInstancesLoading(true);
const request =
@@ -192,13 +227,43 @@ const InstancePage = () => {
? listAliyunInstances(addCredentialId, addRegionId)
: listTencentInstances(addCredentialId, addRegionId);
request
- .then(setCloudInstances)
- .catch(() => message.error('云实例列表加载失败'))
- .finally(() => setCloudInstancesLoading(false));
+ .then((items) => {
+ if (active) {
+ setCloudInstances(items);
+ }
+ })
+ .catch(() => {
+ if (active) {
+ message.error('云实例列表加载失败');
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setCloudInstancesLoading(false);
+ }
+ });
}, 0);
- return () => window.clearTimeout(timer);
+ return () => {
+ active = false;
+ window.clearTimeout(timer);
+ };
}, [vendor, cloudVendor, addCredentialId, addRegionId]);
+ const handleVendorChange = (nextVendor: string) => {
+ setVendor(nextVendor as InstanceVendor);
+ setCredentials([]);
+ setRegions([]);
+ setCloudInstances([]);
+ setCredentialsLoading(false);
+ setRegionsLoading(false);
+ setCloudInstancesLoading(false);
+ addForm.setFieldsValue({
+ credentialId: undefined,
+ regionId: undefined,
+ cloudInstanceId: undefined,
+ });
+ };
+
const handleCredentialChange = () => {
setRegions([]);
setCloudInstances([]);
@@ -499,7 +564,7 @@ const InstancePage = () => {
<Tabs
type="card"
activeKey={vendor}
- onChange={(key) => setVendor(key as InstanceVendor)}
+ onChange={handleVendorChange}
style={{ marginTop: 8, marginBottom: 4 }}
items={VENDOR_OPTIONS.map((option) => ({
key: option.key,