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 5a619f28b fix(web): stop stale async results from leaking across
scopes (#6069)
5a619f28b is described below
commit 5a619f28b1ab20000409157d27ca059bd6726762
Author: Loyal-Young <[email protected]>
AuthorDate: Mon Oct 12 15:31:55 2026 +0800
fix(web): stop stale async results from leaking across scopes (#6069)
Five fixes from the same author for requests and cached state that survive
the scope they
were issued in — an unmounted page, a bumped request generation, a changed
instance, a
shrunk total, a replaced file — folded into a single commit.
- #6051 `web/src/pages/ops/ResourceOperationTimelineDrawer.tsx:180` renders
the timeline and
its pager together only when `records.length > 0`, while `page` lives in
the drawer component
itself (`:79`) and `destroyOnHidden` (`:143`) destroys the content but
not that component, so
after audit retention or cleanup shrinks `total` below the page the user
is on, the drawer
shows an empty state with no pager and the remaining records stay
permanently unreachable —
only a date-range change resets to page 1 (`:169`); clamps `page` to
`Math.max(1, Math.ceil(total / pageSize))` in the `listAuditRecords`
then-handler right after
the `requestVersion` check and lets the existing `page` effect dependency
re-query, which is
the idiom trunk already uses in `ops/alerts.tsx:375-380`,
`settings/CloudCredentialTab.tsx:103-108`,
`settings/DataSourceTab.tsx:160` and two more.
- #6058 `web/src/components/QueueBrowser.tsx:64-76` gives the `[instanceId,
topic]` scope effect
no cleanup, and `requestSeqRef` is advanced only by the effect body
(`:65`) and `loadQueues`
(`:81`), so a queue load or message pull still in flight when the message
page unmounts passes
every `requestId === requestSeqRef.current` gate (`:101 :105 :134 :138`)
and fires
`message.error` — antd's global static API — on a page the user already
left, along with
`setLoading`/`setEntries` on an unmounted component; returns
`() => { requestSeqRef.current += 1; }` from that effect, the unmount
invalidation trunk
already has in `settings/CloudCredentialTab.tsx:132-137`,
`settings/DataSourceTab.tsx:186-191`
and `studio/UserManagement.tsx:202-208`.
- #6060 `web/src/components/MetricsExplorer.tsx:919` clears
`pendingDataSource` only when the
*activated* `dataSourceKey` drops out of `availableDataSources`, but a
pending source is set by
`handleDataSourceChange` (`:850-857`) and by the protected-restore path
without ever touching
`dataSourceKey`, so the credential modal can stay open on a source the
current instance no
longer offers and submitting it makes `activateDataSource` (`:841-843`)
store a key that
`runQuery` (`:614-622`) then discards, falling back to `''` and dropping
the credentials while
the UI still shows that source as selected; adds an effect that routes an
out-of-scope
`pendingDataSource` through the existing cancel path, re-validates the
key at the top of
`handleAuthSubmit` (`:860-861`), and syncs `rangeIdRef` when a replay
checkpoint is restored
(`:888`).
- #6068 `web/src/services/instanceService.ts:40` cleans the in-flight dedup
cache with an
unconditional `finally(() => inflightListRequests.delete(key))`, so when
`invalidateInflightListRequests()` (`:29-31`) clears the map after a
mutation and a newer read
repopulates the same key, the older request settling evicts the newer one
and every later
caller issues its own list GET — breaking the "a read after a write must
not join a stale
snapshot" invariant this module documents at `:26-28`; deletes only when
the cached entry is
still the same promise reference.
- #6069 `web/src/pages/instance/consumer.tsx:783-802` and
`web/src/pages/instance/topic.tsx:1289-1308`
set the filename and open the import modal before awaiting `file.text()`,
leaving the previous
file's `importRows` in state; the OK button's disabled condition
(`consumer.tsx:2480-2485`,
`topic.tsx:1937-1942`) treats those pending rows as importable, so one
click during the read
window bulk-creates file A's payload under file B's name through
`handleImportConsumerGroups`/`handleImportTopics`; clears
`importRows`/`importErrors` and
resets the file input before the await instead of in `finally`, adds an
`importReadIdRef`
generation checked on both the success and the failure path, and advances
it from the modal's
`onCancel` (`consumer.tsx:2473-2475`, `topic.tsx:1930-1932`) and on
unmount.
Eighteen new cases (2 for #6051, 4 for #6058, 3 for #6060, 3 for #6068, 6
for #6069 across the
Topic and Consumer page suites), all mutation-sensitive: reverting the page
clamp means the third
`listAuditRecords` call never happens and the `waitFor` times out,
reverting the QueueBrowser
cleanup makes the two abandoned cases toast once instead of never,
reverting the pending-source
effect leaves the credential dialog on screen for both the picker and the
query-history entry
points, reverting the conditional delete makes the post-mutation shared
read issue a third list
call, and reverting either the pre-await row clearing or the generation
guard brings
`previous-resource` back into the preview and lets the stale payload reach
the bulk-create call —
which the import cases assert on directly, checking the service receives
the current file's rows
and the selected instance. Every member also has a positive control (a
mounted page still
toasts, an in-scope source still submits, an older read still settles
without leaving a residual
cache entry, the current file still imports), so an over-applied guard
fails as well.
Folded: #6051, #6058, #6060, #6068 were closed as folded into this commit
(same author).
---
web/src/components/MetricsExplorer.tsx | 19 +++-
web/src/components/QueueBrowser.tsx | 4 +
.../components/__tests__/MetricsExplorer.test.tsx | 112 ++++++++++++++++++++-
web/src/components/__tests__/QueueBrowser.test.tsx | 62 +++++++++++-
.../pages/instance/__tests__/ConsumerPage.test.tsx | 81 +++++++++++++++
.../pages/instance/__tests__/TopicPage.test.tsx | 83 +++++++++++++++
web/src/pages/instance/consumer.tsx | 22 +++-
web/src/pages/instance/topic.tsx | 22 +++-
.../pages/ops/ResourceOperationTimelineDrawer.tsx | 5 +
.../ResourceOperationTimelineDrawer.test.tsx | 31 ++++++
web/src/services/instanceService.test.ts | 56 +++++++++++
web/src/services/instanceService.ts | 5 +-
12 files changed, 491 insertions(+), 11 deletions(-)
diff --git a/web/src/components/MetricsExplorer.tsx
b/web/src/components/MetricsExplorer.tsx
index 6c3d90b32..39811374a 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -859,6 +859,10 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
const handleAuthSubmit = (values: AuthFormValues) => {
if (!pendingDataSource) return;
+ if (!availableDataSources.some((source) => source.key ===
pendingDataSource.key)) {
+ handleAuthCancel();
+ return;
+ }
const authMode = getDataSourceAuthMode(pendingDataSource.auth);
const credentials =
authMode === 'basic'
@@ -877,7 +881,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
authForm.resetFields();
};
- const handleAuthCancel = () => {
+ const handleAuthCancel = useCallback(() => {
const replay = pendingAuthReplayRef.current;
pendingAuthReplayRef.current = null;
if (replay) {
@@ -886,6 +890,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
// previous source's data and nothing ever queries them.
setProfileId(replay.checkpoint.profileId);
setRangeId(replay.checkpoint.rangeId);
+ rangeIdRef.current = replay.checkpoint.rangeId;
setCustomPromql(replay.checkpoint.customPromql);
if (replay.checkpoint.storedProfileId === null) {
localStorage.removeItem(PROFILE_STORAGE_KEY);
@@ -895,7 +900,17 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
}
setPendingDataSource(null);
authForm.resetFields();
- };
+ }, [authForm]);
+
+ useEffect(() => {
+ if (
+ pendingDataSource &&
+ !availableDataSources.some((source) => source.key ===
pendingDataSource.key)
+ ) {
+ // A pending source is separate from the activated key checked by the
fallback below.
+ handleAuthCancel();
+ }
+ }, [availableDataSources, handleAuthCancel, pendingDataSource]);
useEffect(() => {
let cancelled = false;
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
index 9d73e4181..26adcab79 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -73,6 +73,10 @@ export const useQueueBrowser = (instanceId?: string) => {
pullingRef.current.clear();
setPulling(new Set());
});
+ return () => {
+ // Leaving the page invalidates pending loads and pulls just like a
scope change.
+ requestSeqRef.current += 1;
+ };
}, [instanceId, topic]);
const loadQueues = useCallback(async () => {
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index 3ea7711e1..2f92cbb08 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { App } from 'antd';
+import { App, ConfigProvider } from 'antd';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
@@ -859,6 +859,116 @@ describe('MetricsExplorer', () => {
expect(vi.mocked(queryMetrics).mock.calls.length).toBe(queryMetricsCallsBefore);
});
+ const authenticationView = (instanceId: string) => (
+ <App>
+ <ConfigProvider theme={{ token: { motion: false } }}>
+ <LangProvider>
+ <MetricsExplorer instanceId={instanceId} />
+ </LangProvider>
+ </ConfigProvider>
+ </App>
+ );
+
+ it.each(['picker', 'history'] as const)(
+ 'cancelsPendingAuthenticationWhenItsSourceLeavesScopeTest (%s)',
+ async (origin) => {
+ const user = userEvent.setup();
+ vi.mocked(listDataSources).mockResolvedValue([
+ {
+ key: 'ds-basic',
+ name: 'Protected Prometheus',
+ type: 'Prometheus',
+ url: '',
+ auth: 'Basic Auth',
+ status: 'healthy',
+ instanceIds: ['instance-1'],
+ },
+ ]);
+ localStorage.setItem(
+ METRICS_QUERY_HISTORY_STORAGE_KEY,
+ JSON.stringify([
+ createHistoryEntry({ dataSourceKey: 'ds-basic', dataSourceName:
'Protected Prometheus' }),
+ ]),
+ );
+ const view = render(authenticationView('instance-1'));
+ await screen.findByRole('img', { name: 'Message In TPS time series' });
+ if (origin === 'picker') {
+ await user.click(screen.getByRole('combobox', { name: '数据源' }));
+ await user.click(
+ await screen.findByText('Protected Prometheus', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+ } else {
+ await user.click(screen.getByRole('button', { name: '查询历史' }));
+ const dialog = await screen.findByRole('dialog', { name: '指标查询历史' });
+ const row = within(dialog).getByText('Consumer Lag
Messages').closest('.ant-list-item')!;
+ await user.click(within(row as HTMLElement).getByRole('button', {
name: '恢复' }));
+ }
+ await screen.findByText('凭据仅用于当前数据源,离开该数据源后会被清除。');
+ view.rerender(authenticationView('instance-2'));
+ await waitFor(() =>
+ expect(screen.queryByRole('textbox', { name: '用户名'
})).not.toBeInTheDocument(),
+ );
+ const profile = screen.getByRole('combobox', { name: '指标模板'
}).closest('.ant-select')!;
+ expect(within(profile as HTMLElement).getByText('RocketMQ 5.x
Native')).toBeInTheDocument();
+ expect(
+
screen.getByLabelText('时间范围').querySelector('.ant-segmented-item-selected'),
+ ).toHaveTextContent('1h');
+ await waitFor(() =>
+ expect(queryMetrics).toHaveBeenLastCalledWith(
+ expect.objectContaining({ metric: profiles[0].metrics[0].promql }),
+ ),
+ );
+ const calls = vi.mocked(queryMetrics).mock.calls;
+ const query = calls[calls.length - 1][0];
+ expect(query.end! - query.start!).toBe(3600);
+ expect(queryByDataSource).not.toHaveBeenCalled();
+ },
+ );
+
+ it('canAuthenticateAGlobalSourceAfterTheInstanceChangesTest', async () => {
+ const user = userEvent.setup();
+ vi.mocked(listDataSources).mockResolvedValue([
+ {
+ key: 'global-basic',
+ name: 'Global Protected',
+ type: 'Prometheus',
+ url: '',
+ auth: 'Basic Auth',
+ status: 'healthy',
+ },
+ ]);
+ const view = renderWithProviders(<MetricsExplorer instanceId="instance-1"
/>);
+ await user.click(await screen.findByRole('combobox', { name: '数据源' }));
+ await user.click(
+ await screen.findByText('Global Protected', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+ await screen.findByLabelText('用户名');
+ view.rerender(
+ <App>
+ <LangProvider>
+ <MetricsExplorer instanceId="instance-2" />
+ </LangProvider>
+ </App>,
+ );
+ await user.type(screen.getByLabelText('用户名'), 'reader');
+ await user.type(screen.getByLabelText('密码'), 'test-password');
+ await user.click(screen.getByRole('button', { name: /连\s*接/ }));
+ await waitFor(() =>
+ expect(queryByDataSource).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: 'global-basic',
+ username: 'reader',
+ password: 'test-password',
+ instanceId: 'instance-2',
+ }),
+ ),
+ );
+ });
+
it('prompts for basic credentials and supplies them only to the selected
data source query', async () => {
const user = userEvent.setup();
vi.mocked(listDataSources).mockResolvedValue([
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx
b/web/src/components/__tests__/QueueBrowser.test.tsx
index 5fbd0e679..0285171c3 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -17,7 +17,9 @@
import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+import { StrictMode } from 'react';
+import { message } from 'antd';
import type { MessageRecord, QueueOffset } from '../../api/message';
import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
import { QueueBrowserResults, useQueueBrowser } from '../QueueBrowser';
@@ -130,6 +132,64 @@ describe('QueueBrowser request ownership', () => {
vi.clearAllMocks();
});
+ describe('page lifetime', () => {
+ afterEach(() => vi.restoreAllMocks());
+ it.each([
+ { operation: 'load', abandoned: true },
+ { operation: 'pull', abandoned: true },
+ { operation: 'load', abandoned: false },
+ { operation: 'pull', abandoned: false },
+ ])(
+ 'onlyReportsFailuresOwnedByTheMountedPageTest ($operation/$abandoned)',
+ async ({ operation, abandoned }) => {
+ const errorToast = vi
+ .spyOn(message, 'error')
+ .mockImplementation(() => (() => {}) as ReturnType<typeof
message.error>);
+ let reject!: (error: Error) => void;
+ const pending = new Promise<never>((_resolve, rejectPromise) => {
+ reject = rejectPromise;
+ });
+ vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+
vi.mocked(pullMessageAtOffset).mockResolvedValue(messageRecord('fresh-message'));
+ if (operation === 'load')
vi.mocked(getQueueOffsets).mockReturnValueOnce(pending);
+ else vi.mocked(pullMessageAtOffset).mockReturnValueOnce(pending);
+ const user = userEvent.setup();
+ const view = render(
+ <StrictMode>
+ <QueueBrowserProbe />
+ </StrictMode>,
+ );
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ await user.click(screen.getByRole('button', { name: 'load' }));
+ if (operation === 'pull') {
+ await waitFor(() =>
+
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'),
+ );
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+ }
+ if (abandoned) view.unmount();
+ await act(async () => {
+ reject(new Error('queue request failed'));
+ });
+ expect(errorToast).toHaveBeenCalledTimes(abandoned ? 0 : 1);
+ if (!abandoned) view.unmount();
+ render(
+ <StrictMode>
+ <QueueBrowserProbe />
+ </StrictMode>,
+ );
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ await user.click(screen.getByRole('button', { name: 'load' }));
+ await waitFor(() =>
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'));
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+ await waitFor(() =>
+
expect(screen.getByLabelText('entries')).toHaveTextContent('fresh-message'),
+ );
+ expect(screen.getByLabelText('pulling')).toHaveTextContent('false');
+ },
+ );
+ });
+
it('keeps the newest topic queue load when an older request resolves later',
async () => {
const topicA = createDeferred<QueueOffset[]>();
const topicB = createDeferred<QueueOffset[]>();
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index f318b74df..946436ed5 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -1591,6 +1591,87 @@ describe('Consumer page', () => {
expect(await
screen.findByText('订阅一致性检查失败,当前保留上次检查结果')).toBeInTheDocument();
});
+ const previewCsv = (name: string) =>
+ '"Name","Subscription Mode","Consume Type","Retry Max Times","Subscription
Data Type"\n"' +
+ name +
+ '","Push","CLUSTERING","16","NORMAL"';
+
+ it('clearsPreviousImportRowsBeforeReadingTheNextFileTest', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<ConsumerPage />);
+ await waitFor(() =>
+ expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-1' }),
+ ),
+ );
+ const input = screen.getByTestId('consumer-group-import-file');
+ await user.upload(input, new File([previewCsv('previous-resource')],
'first.csv'));
+ await screen.findByText('previous-resource');
+ await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+ let resolve!: (text: string) => void;
+ const next = new File([], 'second.csv');
+ Object.defineProperty(next, 'text', {
+ value: () =>
+ new Promise<string>((complete) => {
+ resolve = complete;
+ }),
+ });
+ await user.upload(input, next);
+ expect(screen.getByText('导入 Group:second.csv')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+ expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+ expect(consumerService.importConsumerGroups).not.toHaveBeenCalled();
+ await act(async () => resolve(previewCsv('current-resource')));
+ await screen.findByText('current-resource');
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+ await waitFor(() =>
+
expect(consumerService.importConsumerGroups).toHaveBeenCalledWith('instance-1',
[
+ expect.objectContaining({ name: 'current-resource', instanceId:
'instance-1' }),
+ ]),
+ );
+ });
+
+ it.each(['success', 'failure'] as const)(
+ 'ignoresCancelledFileReadCompletionTest (%s)',
+ async (outcome) => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<ConsumerPage />);
+ await waitFor(() =>
+ expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-1' }),
+ ),
+ );
+ const input = screen.getByTestId('consumer-group-import-file');
+ let resolve!: (text: string) => void;
+ let reject!: (error: Error) => void;
+ const first = new File([], 'first.csv');
+ Object.defineProperty(first, 'text', {
+ value: () =>
+ new Promise<string>((complete, fail) => {
+ resolve = complete;
+ reject = fail;
+ }),
+ });
+ await user.upload(input, first);
+ await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+ await user.upload(input, new File([previewCsv('current-resource')],
'second.csv'));
+ await screen.findByText('current-resource');
+ await act(async () => {
+ if (outcome === 'success') resolve(previewCsv('previous-resource'));
+ else reject(new Error('obsolete file read failed'));
+ });
+ expect(screen.getByText('current-resource')).toBeInTheDocument();
+ expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+ expect(screen.queryByText('obsolete file read
failed')).not.toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+ await waitFor(() =>
+
expect(consumerService.importConsumerGroups).toHaveBeenCalledWith('instance-1',
[
+ expect.objectContaining({ name: 'current-resource', instanceId:
'instance-1' }),
+ ]),
+ );
+ },
+ );
+
it('keeps per-row state when consumer group CSV import partially fails',
async () => {
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(groupPage([]));
vi.mocked(consumerService.importConsumerGroups).mockResolvedValue({
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 67645b453..a0e1f7e51 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -993,6 +993,89 @@ describe('TopicPage', () => {
expect(screen.getByText('10.0.2.21:8080')).toBeInTheDocument();
});
+ const previewCsv = (name: string) =>
+ '"Name","Type","Write Queues","Read Queues","Permission"\n"' + name +
'","NORMAL","4","6","RW"';
+
+ it('clearsPreviousImportRowsBeforeReadingTheNextFileTest', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ mockTopicsList([]);
+ instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+ renderWithProviders('/instance/instance-proxy-1/topic');
+ await waitFor(() =>
+ expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+ ),
+ );
+ const input = screen.getByTestId('topic-import-file');
+ await user.upload(input, new File([previewCsv('previous-resource')],
'first.csv'));
+ await screen.findByText('previous-resource');
+ await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+ let resolve!: (text: string) => void;
+ const next = new File([], 'second.csv');
+ Object.defineProperty(next, 'text', {
+ value: () =>
+ new Promise<string>((complete) => {
+ resolve = complete;
+ }),
+ });
+ await user.upload(input, next);
+ expect(screen.getByText('导入 Topic:second.csv')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+ expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+ expect(topicServiceMocks.importTopics).not.toHaveBeenCalled();
+ await act(async () => resolve(previewCsv('current-resource')));
+ await screen.findByText('current-resource');
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+ await waitFor(() =>
+
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1',
[
+ expect.objectContaining({ name: 'current-resource', instanceId:
'instance-proxy-1' }),
+ ]),
+ );
+ });
+
+ it.each(['success', 'failure'] as const)(
+ 'ignoresCancelledFileReadCompletionTest (%s)',
+ async (outcome) => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ mockTopicsList([]);
+ instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+ renderWithProviders('/instance/instance-proxy-1/topic');
+ await waitFor(() =>
+ expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+ ),
+ );
+ const input = screen.getByTestId('topic-import-file');
+ let resolve!: (text: string) => void;
+ let reject!: (error: Error) => void;
+ const first = new File([], 'first.csv');
+ Object.defineProperty(first, 'text', {
+ value: () =>
+ new Promise<string>((complete, fail) => {
+ resolve = complete;
+ reject = fail;
+ }),
+ });
+ await user.upload(input, first);
+ await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+ await user.upload(input, new File([previewCsv('current-resource')],
'second.csv'));
+ await screen.findByText('current-resource');
+ await act(async () => {
+ if (outcome === 'success') resolve(previewCsv('previous-resource'));
+ else reject(new Error('obsolete file read failed'));
+ });
+ expect(screen.getByText('current-resource')).toBeInTheDocument();
+ expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+ expect(screen.queryByText('obsolete file read
failed')).not.toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+ await waitFor(() =>
+
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1',
[
+ expect.objectContaining({ name: 'current-resource', instanceId:
'instance-proxy-1' }),
+ ]),
+ );
+ },
+ );
+
it('imports valid topic CSV rows through the backend batch service with the
selected instance', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([]);
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index c118f45ad..59294a7ca 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -289,6 +289,13 @@ const ConsumerPageContent = ({
const [stackError, setStackError] = useState<string | null>(null);
const [selectedStackClient, setSelectedStackClient] =
useState<ConsumerInstance | null>(null);
const importInputRef = useRef<HTMLInputElement>(null);
+ const importReadIdRef = useRef(0);
+ useEffect(
+ () => () => {
+ importReadIdRef.current += 1;
+ },
+ [],
+ );
const [importModalOpen, setImportModalOpen] = useState(false);
const [importFilename, setImportFilename] = useState('');
const [importRows, setImportRows] =
useState<ResourceImportRow<Partial<ConsumerGroup>>[]>([]);
@@ -796,19 +803,25 @@ const ConsumerPageContent = ({
message.error('请先选择实例');
return;
}
+ const readId = ++importReadIdRef.current;
+ // Clear the previous preview before the file read yields: those rows must
not be imported
+ // under this file's name while its contents are still loading.
+ setImportRows([]);
+ setImportErrors([]);
+ if (importInputRef.current) importInputRef.current.value = '';
setImportFilename(file.name);
setImporting(false);
setImportModalOpen(true);
try {
const records = parseCsvTable(await file.text());
+ if (readId !== importReadIdRef.current) return;
const validation = validateConsumerGroupCsvImport(records,
selectedInstanceId || undefined);
setImportRows(validation.rows);
setImportErrors(validation.errors);
} catch (error) {
+ if (readId !== importReadIdRef.current) return;
setImportRows([]);
setImportErrors([error instanceof Error ? error.message : 'CSV 解析失败']);
- } finally {
- if (importInputRef.current) importInputRef.current.value = '';
}
};
@@ -2484,7 +2497,10 @@ const ConsumerPageContent = ({
title={`导入 Group${importFilename ? `:${importFilename}` : ''}`}
open={importModalOpen}
onCancel={() => {
- if (!importing) setImportModalOpen(false);
+ if (!importing) {
+ importReadIdRef.current += 1;
+ setImportModalOpen(false);
+ }
}}
onOk={() => void handleImportConsumerGroups()}
okText={importRows.some((row) => row.status === 'failed') ? '重试失败项' :
'开始导入'}
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index cb62bbbe4..1be6ee7a4 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -399,6 +399,13 @@ const TopicPageContent = ({
MessagePropertyInput[] | undefined;
const { modal } = App.useApp();
const importInputRef = useRef<HTMLInputElement>(null);
+ const importReadIdRef = useRef(0);
+ useEffect(
+ () => () => {
+ importReadIdRef.current += 1;
+ },
+ [],
+ );
const [importModalOpen, setImportModalOpen] = useState(false);
const [importFilename, setImportFilename] = useState('');
const [importRows, setImportRows] =
useState<ResourceImportRow<Partial<Topic>>[]>([]);
@@ -1291,19 +1298,25 @@ const TopicPageContent = ({
message.error('请先选择实例');
return;
}
+ const readId = ++importReadIdRef.current;
+ // Clear the previous preview before the file read yields: those rows must
not be imported
+ // under this file's name while its contents are still loading.
+ setImportRows([]);
+ setImportErrors([]);
+ if (importInputRef.current) importInputRef.current.value = '';
setImportFilename(file.name);
setImporting(false);
setImportModalOpen(true);
try {
const records = parseCsvTable(await file.text());
+ if (readId !== importReadIdRef.current) return;
const validation = validateTopicCsvImport(records, selectedInstanceId ||
undefined);
setImportRows(validation.rows);
setImportErrors(validation.errors);
} catch (error) {
+ if (readId !== importReadIdRef.current) return;
setImportRows([]);
setImportErrors([error instanceof Error ? error.message : 'CSV 解析失败']);
- } finally {
- if (importInputRef.current) importInputRef.current.value = '';
}
};
@@ -1928,7 +1941,10 @@ const TopicPageContent = ({
title={`导入 Topic${importFilename ? `:${importFilename}` : ''}`}
open={importModalOpen}
onCancel={() => {
- if (!importing) setImportModalOpen(false);
+ if (!importing) {
+ importReadIdRef.current += 1;
+ setImportModalOpen(false);
+ }
}}
onOk={() => void handleImportTopics()}
okText={importRows.some((row) => row.status === 'failed') ? '重试失败项' :
'开始导入'}
diff --git a/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
b/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
index d2904b530..330793593 100644
--- a/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
+++ b/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
@@ -98,6 +98,11 @@ const ResourceOperationTimelineDrawer = ({
void listAuditRecords({ page, pageSize, ...filter })
.then((result) => {
if (requestVersion.current !== version) return;
+ const lastPage = Math.max(1, Math.ceil(result.total / pageSize));
+ if (page > lastPage) {
+ setPage(lastPage);
+ return;
+ }
setResponse({
queryKey,
records: result.items,
diff --git
a/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
b/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
index eb30fabba..fca63859c 100644
--- a/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
+++ b/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
@@ -169,6 +169,37 @@ describe('ResourceOperationTimelineDrawer', () => {
);
});
+ describe('retention pagination', () => {
+ afterEach(() => vi.mocked(opsService.listAuditRecords).mockReset());
+
+ it.each([0, 5])('recoversAfterRetentionRemovesTheCurrentPageTest
(total=%s)', async (total) => {
+ const user = userEvent.setup();
+ vi.mocked(opsService.listAuditRecords)
+ .mockResolvedValueOnce({ items: [timelineRecord], total: 21, page: 1,
size: 20 })
+ .mockResolvedValueOnce({ items: [], total, page: 2, size: 20 })
+ .mockResolvedValueOnce({
+ items: total ? [{ ...timelineRecord, operator: 'remaining-user' }] :
[],
+ total,
+ page: 1,
+ size: 20,
+ });
+ renderDrawer();
+ await screen.findByText(/ops-user/);
+ await user.click(screen.getByTitle('2'));
+ await waitFor(() =>
expect(opsService.listAuditRecords).toHaveBeenCalledTimes(3));
+ expect(opsService.listAuditRecords).toHaveBeenLastCalledWith({
+ page: 1,
+ pageSize: 20,
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ });
+ if (total) expect(await
screen.findByText(/remaining-user/)).toBeInTheDocument();
+ else expect(screen.queryByText(/ops-user/)).not.toBeInTheDocument();
+ expect(opsService.listAuditRecords).toHaveBeenCalledTimes(3);
+ });
+ });
+
it('applies the selected date range to the resource query and export', async
() => {
const user = userEvent.setup();
renderDrawer();
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index 50b78ca92..f119a4310 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -259,6 +259,62 @@ describe('instanceService dedupe invalidation after
mutations', () => {
return holder as { resolve: (value: Instance[]) => void };
}
+ it.each(['success', 'failure'] as const)(
+ 'keepsThePostMutationRequestSharedWhenAnOlderReadSettlesTest (%s)',
+ async (outcome) => {
+ let resolveOld!: (value: Instance[]) => void;
+ let rejectOld!: (error: Error) => void;
+ let resolveFresh!: (value: Instance[]) => void;
+ const old = new Promise<Instance[]>((resolve, reject) => {
+ resolveOld = resolve;
+ rejectOld = reject;
+ });
+ const fresh = new Promise<Instance[]>((resolve) => {
+ resolveFresh = resolve;
+ });
+ const expected = [instanceFixture('kept', 'updated')];
+ instanceApiMock.listInstances
+ .mockReturnValueOnce(old)
+ .mockReturnValueOnce(fresh)
+ .mockResolvedValue(expected);
+ const first = listInstances().catch((error: unknown) => error);
+ await updateInstance({ instanceId: 'kept', remark: 'updated' });
+ const second = listInstances();
+ if (outcome === 'success') resolveOld([instanceFixture('kept', 'old')]);
+ else rejectOld(new Error('obsolete read failed'));
+ await first;
+ const third = listInstances();
+ // Settle all callers before asserting so a failing regression leaves no
pending entries.
+ resolveFresh(expected);
+ const [secondResult, thirdResult] = await Promise.all([second, third]);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ expect(secondResult).toEqual(expected);
+ expect(thirdResult).toEqual(expected);
+ expect(secondResult).not.toBe(thirdResult);
+ expect(secondResult[0]).not.toBe(thirdResult[0]);
+ secondResult[0].remark = 'caller-local change';
+ expect(thirdResult[0].remark).toBe('updated');
+ await listInstances();
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(3);
+ },
+ );
+
+ it('retriesAfterTheCurrentSharedReadFailsTest', async () => {
+ let reject!: (error: Error) => void;
+ const pending = new Promise<Instance[]>((_resolve, fail) => {
+ reject = fail;
+ });
+
instanceApiMock.listInstances.mockReturnValueOnce(pending).mockResolvedValue([]);
+ const first = listInstances();
+ const second = listInstances();
+ const settled = Promise.allSettled([first, second]);
+ reject(new Error('current read failed'));
+ expect((await settled).map((result) =>
result.status)).toEqual(['rejected', 'rejected']);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(1);
+ await expect(listInstances()).resolves.toEqual([]);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ });
+
it('does not serve the pre-create snapshot to a list request issued after
createInstance', async () => {
const before = [instanceFixture('kept')];
const after = [instanceFixture('kept'), instanceFixture('created')];
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index 8e66addcb..f7a950532 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -37,7 +37,10 @@ export function listInstances(query: InstanceQuery = {}):
Promise<Instance[]> {
if (inflight) {
return inflight.then((items) => items.map(copyInstance));
}
- const request = fetchInstances(query, mockMode).finally(() =>
inflightListRequests.delete(key));
+ const request = fetchInstances(query, mockMode).finally(() => {
+ // A mutation may have replaced this request; only remove the entry we
installed.
+ if (inflightListRequests.get(key) === request)
inflightListRequests.delete(key);
+ });
inflightListRequests.set(key, request);
return request.then((items) => items.map(copyInstance));
}