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;
   }
 

Reply via email to