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 68e344192 fix(web): reject incomplete list scans, cancel stale metric
timers, align mock group search (#6709)
68e344192 is described below
commit 68e34419289e08749198b56ad5d286d0a6d0dfc2
Author: Loyal-Young <[email protected]>
AuthorDate: Mon Oct 19 17:05:44 2026 +0800
fix(web): reject incomplete list scans, cancel stale metric timers, align
mock group search (#6709)
Batched integration of @Loyal-Young's list-scan, consumer/DLQ and metrics
fixes. This branch carries
five PRs' worth of changes; the individual PRs are closed as folded into
this one.
- #6709 — reject an incomplete full-list page in the four scan helpers
instead of exporting a
silently truncated list.
- #6910 — export an unavailable total lag as unknown rather than a measured
zero.
- #6909 — match the mock group search to the backend semantics.
- #6713 — wait for an interactive table before the DLQ resend assertion.
- #6464 — cancel obsolete data-source fallback timers in the metrics
explorer.
Maintainer fixes on top: the DLQ resend wait now reuses the existing
`expectInteractive` helper, and
the metrics fallback-cancellation test asserts both branches explicitly
(the assertion used to sit
inside an `if`, so one path ran zero assertions) and scopes its unmount
check to the render container.
Verified locally against `rocketmq-studio`: the six service/component
suites 115/115 green plus
`DLQPage` 34/34 green, eslint clean.
---
web/src/api/settings.test.ts | 18 +++++++
web/src/api/settings.ts | 5 +-
web/src/api/studioUsers.test.ts | 21 ++++++++
web/src/api/studioUsers.ts | 5 +-
web/src/components/MetricsExplorer.tsx | 3 +-
.../components/__tests__/MetricsExplorer.test.tsx | 56 ++++++++++++++++++++++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 13 +++--
web/src/services/consumerService.test.ts | 42 ++++++++++++++++
web/src/services/consumerService.ts | 11 ++++-
web/src/services/messageService.test.ts | 11 +++++
web/src/services/messageService.ts | 6 ++-
web/src/services/topicService.test.ts | 22 ++++++++-
web/src/services/topicService.ts | 5 +-
13 files changed, 207 insertions(+), 11 deletions(-)
diff --git a/web/src/api/settings.test.ts b/web/src/api/settings.test.ts
index 7267db136..a12457089 100644
--- a/web/src/api/settings.test.ts
+++ b/web/src/api/settings.test.ts
@@ -93,6 +93,24 @@ describe('data sources API', () => {
);
});
+ it('rejects a missing data source page while the response still reports
unseen rows', async () => {
+ mock.onGet('/settings/datasources/page').reply((config) => [
+ 200,
+ {
+ code: 200,
+ data: {
+ items: config.params.page === 1 ? [source] : [],
+ total: 2,
+ page: config.params.page,
+ size: 100,
+ },
+ },
+ ]);
+
+ await expect(listAllDataSources()).rejects.toThrow('Incomplete data source
list');
+ expect(mock.history.get).toHaveLength(2);
+ });
+
it('uses a key query parameter for deletion and sends test auth details',
async () => {
mock.onPost('/settings/datasources/delete').reply((config) => {
expect(config.params).toEqual({ key: source.key });
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index 63561c4fe..b01e15248 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -132,9 +132,12 @@ export async function listAllDataSources(params: {
search?: string; type?: strin
});
allDataSources.push(...result.items);
const total = result.total ?? allDataSources.length;
- if (result.items.length === 0 || allDataSources.length >= total) {
+ if (allDataSources.length >= total) {
return allDataSources;
}
+ if (result.items.length === 0) {
+ throw new Error(`Incomplete data source list: received
${allDataSources.length} of ${total}`);
+ }
page += 1;
}
diff --git a/web/src/api/studioUsers.test.ts b/web/src/api/studioUsers.test.ts
index 191557c17..be911148f 100644
--- a/web/src/api/studioUsers.test.ts
+++ b/web/src/api/studioUsers.test.ts
@@ -96,6 +96,27 @@ describe('studio users API', () => {
]);
});
+ it('rejects a missing user page while the response still reports unseen
rows', async () => {
+ mock.onGet('/studio-users').reply((config) => [
+ 200,
+ {
+ code: 200,
+ data: {
+ items:
+ config.params.page === 1
+ ? [{ id: 7, username: 'operator', admin: false, enabled: true }]
+ : [],
+ total: 2,
+ page: config.params.page,
+ size: 100,
+ },
+ },
+ ]);
+
+ await
expect(loadStudioUsersForExport(exportQuery)).rejects.toThrow('Incomplete user
list');
+ expect(mock.history.get).toHaveLength(2);
+ });
+
it('loads the global Studio session overview', async () => {
mock.onGet('/studio-users/sessions/overview').reply(200, {
code: 200,
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index 3e465fe59..9f89c9ecc 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -108,7 +108,10 @@ export const listAllStudioUsers = async (
});
allUsers.push(...result.items);
const total = result.total ?? allUsers.length;
- if (result.items.length === 0 || allUsers.length >= total) return allUsers;
+ if (allUsers.length >= total) return allUsers;
+ if (result.items.length === 0) {
+ throw new Error(`Incomplete user list: received ${allUsers.length} of
${total}`);
+ }
page += 1;
}
throw new Error(`Studio user export exceeded ${STUDIO_USER_MAX_EXPORT_PAGES}
pages`);
diff --git a/web/src/components/MetricsExplorer.tsx
b/web/src/components/MetricsExplorer.tsx
index 2cbae78f0..a33d2ec86 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -937,7 +937,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
useEffect(() => {
if (dataSourceKey && !availableDataSources.some((source) => source.key ===
dataSourceKey)) {
- window.setTimeout(() => {
+ const timeoutId = window.setTimeout(() => {
// Keep the ref in sync with the state; queries read the ref, so a
stale key would
// keep hitting the de-registered data source while the UI shows the
default.
dataSourceCredentialsRef.current = null;
@@ -949,6 +949,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
void runCustomQuery(appliedCustomPromql, selectedRange);
}
}, 0);
+ return () => window.clearTimeout(timeoutId);
}
}, [
availableDataSources,
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index 21cf2c0ca..f2fa1c033 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -210,6 +210,62 @@ const createDeferred = <T,>() => {
};
describe('MetricsExplorer', () => {
+ it.each(['unmount', 'return'] as const)(
+ 'cancels an obsolete scoped-source fallback on %s Test',
+ async (nextAction) => {
+ const user = userEvent.setup();
+ vi.mocked(listDataSources).mockResolvedValue([
+ {
+ key: 'scoped-a',
+ name: 'Scoped A',
+ type: 'Prometheus',
+ url: '',
+ auth: 'None',
+ status: 'healthy',
+ instanceIds: ['instance-a'],
+ },
+ ]);
+ const scoped = (instanceId: string) => (
+ <App>
+ <LangProvider>
+ <MetricsExplorer instanceId={instanceId} />
+ </LangProvider>
+ </App>
+ );
+ const view = render(scoped('instance-a'));
+ await user.click(await screen.findByRole('combobox', { name: '数据源' }));
+ await user.click(
+ await screen.findByText('Scoped A', { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+ expect(queryByDataSource).toHaveBeenCalledWith(
+ expect.objectContaining({ key: 'scoped-a', instanceId: 'instance-a'
}),
+ ),
+ );
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ try {
+ view.rerender(scoped('instance-b'));
+ if (nextAction === 'unmount') view.unmount();
+ else view.rerender(scoped('instance-a'));
+ vi.mocked(queryMetrics).mockClear();
+ vi.mocked(queryByDataSource).mockClear();
+ act(() => {
+ vi.runOnlyPendingTimers();
+ });
+ expect(queryMetrics).not.toHaveBeenCalled();
+ expect(queryByDataSource).not.toHaveBeenCalled();
+ if (nextAction === 'unmount') {
+ // Scoped to the render container: antd keeps select-option portals
on document.body.
+ expect(view.container).toBeEmptyDOMElement();
+ } else {
+ expect(screen.getAllByTitle('Scoped A').length).toBeGreaterThan(0);
+ }
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+
it('loads a metric profile and renders its Prometheus series', async () => {
renderWithProviders(<MetricsExplorer />);
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index d9774c473..71001b301 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -504,12 +504,16 @@ describe('DLQ page', () => {
const orderRow = (await screen.findByText('cg-order')).closest('tr');
if (!orderRow) throw new Error('DLQ group row not found');
- await user.click(within(orderRow).getByRole('button', { name: /消息明细/ }));
+ const orderDetailButton = within(orderRow).getByRole('button', { name:
/消息明细/ });
+ await expectInteractive(orderDetailButton);
+ await user.click(orderDetailButton);
expect(await screen.findByText('order-dead-letter-1')).toBeInTheDocument();
const paymentRow = (await
screen.findByText('-cg-"payment"')).closest('tr');
if (!paymentRow) throw new Error('second DLQ group row not found');
- await user.click(within(paymentRow).getByRole('button', { name: /消息明细/ }));
+ const paymentDetailButton = within(paymentRow).getByRole('button', { name:
/消息明细/ });
+ await expectInteractive(paymentDetailButton);
+ await user.click(paymentDetailButton);
// the drawer already belongs to the second group while its request is
still open: the first
// group's rows and the total that enables the export must be gone before
the response lands
@@ -957,7 +961,10 @@ describe('DLQ page', () => {
const orderRow = (await screen.findByText('cg-order')).closest('tr');
if (!orderRow) throw new Error('DLQ group row not found');
- await user.click(within(orderRow).getByRole('button', { name: '重投消息' }));
+ const resendButton = within(orderRow).getByRole('button', { name: '重投消息'
});
+ // The row text can appear before antd removes the table's loading overlay.
+ await expectInteractive(resendButton);
+ await user.click(resendButton);
await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'),
'orders-retry');
await user.click(screen.getByRole('button', { name: '确认重投' }));
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index 893c3e722..e42efe3a0 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -338,6 +338,25 @@ describe('consumer service mock data', () => {
mode.mock = true;
}
});
+ it('rejects an empty API page while the reported group total is still
unseen', async () => {
+ mode.mock = false;
+ metadataApi.listConsumerGroupPage.mockReset();
+ metadataApi.listConsumerGroupPage
+ .mockResolvedValueOnce({
+ items: [{ name: 'cg-a', subscribedTopics: [], instances: [] }],
+ total: 2,
+ page: 1,
+ size: 100,
+ })
+ .mockResolvedValueOnce({ items: [], total: 2, page: 2, size: 100 });
+ try {
+ await expect(listAllConsumerGroups()).rejects.toThrow('Incomplete
consumer group list');
+ expect(metadataApi.listConsumerGroupPage).toHaveBeenCalledTimes(2);
+ } finally {
+ mode.mock = true;
+ metadataApi.listConsumerGroupPage.mockReset();
+ }
+ });
it('returns copied progress and subscription rows', async () => {
const firstProgress = await getConsumerProgress('cg-order-notify');
const firstSubscriptions = await
getConsumerSubscriptions('cg-order-notify');
@@ -508,6 +527,29 @@ describe('consumer service mock data', () => {
});
describe('consumer group CSV export', () => {
+ it('exports an unavailable total lag as unknown while preserving measured
values', async () => {
+ const unavailable = {
+ ...mockConsumerGroups[0],
+ name: 'cg-lag-unavailable',
+ totalLag: -1,
+ };
+ const confirmedZero = { ...unavailable, name: 'cg-lag-zero', totalLag: 0 };
+ const measured = { ...unavailable, name: 'cg-lag-measured', totalLag: 1234
};
+ mockConsumerGroups.push(unavailable, confirmedZero, measured);
+ try {
+ const csv = await exportConsumerGroups({
+ names: ['cg-lag-unavailable', 'cg-lag-zero', 'cg-lag-measured'],
+ });
+
+ expect(csv).toMatch(/"cg-lag-unavailable",(?:"[^"]*",){5}"unknown",/);
+ expect(csv).toMatch(/"cg-lag-zero",(?:"[^"]*",){5}"0",/);
+ expect(csv).toMatch(/"cg-lag-measured",(?:"[^"]*",){5}"1234",/);
+ expect(csv).not.toContain(`"'-1"`);
+ } finally {
+ mockConsumerGroups.splice(-3);
+ }
+ });
+
it('renders an unavailable connection count as unknown instead of the
sentinel', async () => {
const unavailable = {
...mockConsumerGroups[0],
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index aec166c7e..9663345fd 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -18,6 +18,7 @@ import type {
} from '../api/metadata';
import { mockConsumerGroups, mockQueueProgress, mockSubscriptions } from
'../mock/consumers';
import { formatOnlineInstances } from '../utils/consumerConnections';
+import { isLagAvailable } from '../utils/consumerLag';
import { buildCsv, type CsvColumn } from '../utils/download';
const consumerGroupsState = mockConsumerGroups as unknown as (ConsumerGroup & {
@@ -38,7 +39,10 @@ const GROUP_EXPORT_COLUMNS: CsvColumn<ConsumerGroup>[] = [
header: 'Online Instances',
value: (group) => formatOnlineInstances(group.onlineInstances, 'unknown'),
},
- { header: 'Total Lag', value: (group) => group.totalLag },
+ {
+ header: 'Total Lag',
+ value: (group) => (isLagAvailable(group.totalLag) ? group.totalLag :
'unknown'),
+ },
{ header: 'Delay Seconds', value: (group) => group.delaySeconds },
{ header: 'Subscription Data Type', value: (group) =>
group.subscriptionDataType },
{ header: 'Delivery Order Type', value: (group) => group.deliveryOrderType },
@@ -161,9 +165,12 @@ export async function listAllConsumerGroups(
});
groups.push(...result.items);
const total = result.total ?? groups.length;
- if (result.items.length === 0 || groups.length >= total) {
+ if (groups.length >= total) {
return groups.map(normalizeConsumerGroup);
}
+ if (result.items.length === 0) {
+ throw new Error(`Incomplete consumer group list: received
${groups.length} of ${total}`);
+ }
page += 1;
}
diff --git a/web/src/services/messageService.test.ts
b/web/src/services/messageService.test.ts
index c44bb965d..218349056 100644
--- a/web/src/services/messageService.test.ts
+++ b/web/src/services/messageService.test.ts
@@ -118,6 +118,17 @@ describe('message service mock data', () => {
expect(all.items).toHaveLength(1);
expect(all.total).toBeGreaterThanOrEqual(1);
});
+
+ it('matches the backend DLQ search with case-insensitive trimmed terms',
async () => {
+ const byGroup = await listDLQGroups('instance-1', ' ORDER ', 1, 20);
+ expect(byGroup.items.some((group) => group.groupName ===
'cg-order-processor')).toBe(true);
+
+ const byTopic = await listDLQGroups('instance-1', ' %DLQ%CG-ORDER ', 1,
20);
+ expect(byTopic.items.some((group) => group.groupName ===
'cg-order-processor')).toBe(true);
+
+ const blank = await listDLQGroups('instance-1', ' ', 1, 20);
+ expect(blank.total).toBe((await listDLQGroups('instance-1')).total);
+ });
});
describe('DLQ mock pagination contract', () => {
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index 4bb4c39ea..eea687041 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -130,8 +130,12 @@ export async function listDLQGroups(
if (page < 1 || pageSize < 1 || pageSize > 100) {
return Promise.reject(new Error('Invalid page or pageSize'));
}
+ const normalizedSearch = search?.trim().toLowerCase();
const groups = (mockDLQGroups as unknown as DLQGroup[]).filter(
- (group) => !search || group.groupName.includes(search) ||
group.dlqTopic.includes(search),
+ (group) =>
+ !normalizedSearch ||
+ group.groupName.toLowerCase().includes(normalizedSearch) ||
+ group.dlqTopic.toLowerCase().includes(normalizedSearch),
);
const from = Math.min((page - 1) * pageSize, groups.length);
return {
diff --git a/web/src/services/topicService.test.ts
b/web/src/services/topicService.test.ts
index 6f5f063d5..335837d5d 100644
--- a/web/src/services/topicService.test.ts
+++ b/web/src/services/topicService.test.ts
@@ -25,7 +25,13 @@ import {
sendTopicMessage,
} from './topicService';
-vi.mock('./dataMode', () => ({ isMockMode: () => true }));
+const { mode, metadataApi } = vi.hoisted(() => ({
+ mode: { mock: true },
+ metadataApi: { listTopicsPage: vi.fn() },
+}));
+
+vi.mock('./dataMode', () => ({ isMockMode: () => mode.mock }));
+vi.mock('../api/metadata', () => metadataApi);
vi.mock('../config', () => ({
API_BASE_URL: '/api',
}));
@@ -101,6 +107,20 @@ describe('topic service mock data', () => {
expect(exportedTopics).toEqual(directTopics);
});
+ it('rejects an empty API page while the reported topic total is still
unseen', async () => {
+ mode.mock = false;
+ metadataApi.listTopicsPage
+ .mockResolvedValueOnce({ items: [{ name: 'topic-a' }], total: 2, page:
1, size: 100 })
+ .mockResolvedValueOnce({ items: [], total: 2, page: 2, size: 100 });
+ try {
+ await expect(listAllTopics()).rejects.toThrow('Incomplete topic list');
+ expect(metadataApi.listTopicsPage).toHaveBeenCalledTimes(2);
+ } finally {
+ mode.mock = true;
+ metadataApi.listTopicsPage.mockReset();
+ }
+ });
+
it('rejects duplicate topic creates across mock instances and clusters',
async () => {
const existing = (await listTopics({ search: 'order-create' }))[0];
const before = await listTopics({ search: existing.name });
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index a55bae5d9..472dbe525 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -97,7 +97,10 @@ export const listAllTopics = async (params: TopicQuery =
{}): Promise<Topic[]> =
const result = await listTopicsPage({ ...params, page, pageSize:
EXPORT_PAGE_SIZE });
topics.push(...result.items);
const total = result.total ?? topics.length;
- if (result.items.length === 0 || topics.length >= total) return topics;
+ if (topics.length >= total) return topics;
+ if (result.items.length === 0) {
+ throw new Error(`Incomplete topic list: received ${topics.length} of
${total}`);
+ }
page += 1;
}