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 746bdc8e6 feat(message): server-side pagination for message query
(#2531)
746bdc8e6 is described below
commit 746bdc8e6185ef4358bfeb1e838e9a0984def4f1
Author: aias00 <[email protected]>
AuthorDate: Sat Aug 22 16:12:16 2026 +0800
feat(message): server-side pagination for message query (#2531)
---
.../studio/instance/message/MessageController.java | 10 ++++
.../instance/message/MessageQueryPageVO.java | 35 +++++++++++++
.../studio/instance/message/MessageService.java | 17 ++++++
.../instance/message/MessageControllerTest.java | 16 ++++++
.../instance/message/MessageServiceTest.java | 20 ++++++++
web/src/api/message.test.ts | 13 ++++-
web/src/api/message.ts | 15 ++++++
.../pages/instance/__tests__/MessagePage.test.tsx | 60 ++++++++++++++++++----
.../__tests__/MessagePageAsyncState.test.tsx | 12 ++++-
web/src/pages/instance/message.tsx | 47 ++++++++++++++---
web/src/services/messageService.ts | 23 ++++++++-
11 files changed, 247 insertions(+), 21 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageController.java
index 3fb6da3c6..586944ff8 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageController.java
@@ -45,6 +45,16 @@ public class MessageController {
return Result.ok(messageService.queryMessages(instanceId, topic,
msgId, tag, key, startTime, endTime));
}
+ @GetMapping("/page")
+ public Result<MessageQueryPageVO> queryMessagesPage(@RequestParam String
instanceId,
+ @RequestParam(required = false) String topic,
@RequestParam(required = false) String msgId,
+ @RequestParam(required = false) String tag, @RequestParam(required
= false) String key,
+ @RequestParam(required = false) Long startTime,
@RequestParam(required = false) Long endTime,
+ @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int pageSize) {
+ return Result.ok(messageService.queryMessagesPage(instanceId, topic,
msgId, tag, key, startTime, endTime,
+ page, pageSize));
+ }
+
@GetMapping("/{msgId}/trace")
public Result<TraceRecordVO> getMessageTrace(@PathVariable String msgId,
@RequestParam String instanceId,
// Optional for the
Apache/Aliyun providers; the Tencent
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageQueryPageVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageQueryPageVO.java
new file mode 100644
index 000000000..99dd893e9
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageQueryPageVO.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class MessageQueryPageVO {
+ private List<MessageRecordVO> items;
+ private long total;
+ private int page;
+ private int size;
+ private boolean resultMayBeTruncated;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
index b5319be5f..fbeab1ab5 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
@@ -31,6 +31,8 @@ import java.util.List;
public class MessageService {
private static final long MAX_TOPIC_QUERY_WINDOW_MILLIS = 7L * 24 * 60 *
60 * 1000;
+ private static final int MAX_PAGE_SIZE = 100;
+ private static final int TOPIC_QUERY_RESULT_LIMIT = 200;
private final MessageProvider messageProvider;
private final InstanceProviderRegistry providerRegistry;
@@ -47,6 +49,21 @@ public class MessageService {
return result;
}
+ public MessageQueryPageVO queryMessagesPage(String instanceId, String
topic, String msgId, String tag,
+ String key, Long startTime,
Long endTime, int page, int pageSize) {
+ if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+ throw new BusinessException(400, "page must be positive and
pageSize must be between 1 and 100");
+ }
+ List<MessageRecordVO> result = queryMessages(instanceId, topic, msgId,
tag, key, startTime, endTime);
+ long offset = (long) (page - 1) * pageSize;
+ int from = (int) Math.min(offset, result.size());
+ int to = Math.min(from + pageSize, result.size());
+ boolean topicQuery = StringUtils.hasText(topic) &&
!StringUtils.hasText(msgId)
+ && !StringUtils.hasText(key);
+ return MessageQueryPageVO.builder().items(result.subList(from,
to)).total(result.size()).page(page)
+ .size(pageSize).resultMayBeTruncated(topicQuery &&
result.size() >= TOPIC_QUERY_RESULT_LIMIT).build();
+ }
+
public TraceRecordVO getMessageTrace(String instanceId, String msgId,
String topic) {
if (!StringUtils.hasText(msgId)) {
throw new BusinessException(400, "msgId is required");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageControllerTest.java
index e12071588..5dba50b20 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageControllerTest.java
@@ -71,6 +71,22 @@ class MessageControllerTest {
eq(1784246400000L), eq(1784332800000L));
}
+ @Test
+ void pagedQueryShouldPassPageAndReturnTruncationState() throws Exception {
+ MessageQueryPageVO page =
MessageQueryPageVO.builder().items(List.of()).total(200).page(2).size(50)
+ .resultMayBeTruncated(true).build();
+ when(messageService.queryMessagesPage("instance-a", "orders", null,
null, null, 1000L, 2000L, 2, 50))
+ .thenReturn(page);
+
+ mockMvc.perform(get("/api/messages/page").param("instanceId",
"instance-a").param("topic", "orders")
+ .param("startTime", "1000").param("endTime",
"2000").param("page", "2"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(200))
+
.andExpect(jsonPath("$.data.resultMayBeTruncated").value(true));
+
+ verify(messageService).queryMessagesPage("instance-a", "orders", null,
null, null, 1000L, 2000L, 2, 50);
+ }
+
@Test
void messageTraceShouldPassInstanceId() throws Exception {
TraceRecordVO trace =
TraceRecordVO.builder().nodes(List.of()).consumerStatus(List.of()).build();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
index d30b43dd5..2c7a9bfc8 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
@@ -19,6 +19,7 @@ import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify;
@@ -123,4 +124,23 @@ class MessageServiceTest {
verifyNoInteractions(provider, registry);
}
+
+ @Test
+ void pageQuerySlicesProviderResultAndSignalsBoundedTopicResult() {
+ MessageProvider provider = mock(MessageProvider.class);
+ InstanceProviderRegistry registry =
mock(InstanceProviderRegistry.class);
+ QueryHistoryService history = mock(QueryHistoryService.class);
+ MessageService service = new MessageService(provider, registry,
history);
+ when(registry.byInstanceId("instance-a")).thenReturn(Optional.empty());
+ when(provider.queryMessages("instance-a", "TopicA", null, null, null,
1000L, 2000L))
+ .thenReturn(java.util.stream.IntStream.range(0, 200)
+ .mapToObj(index ->
MessageRecordVO.builder().msgId("msg-" + index).build()).toList());
+
+ MessageQueryPageVO page = service.queryMessagesPage("instance-a",
"TopicA", null, null, null,
+ 1000L, 2000L, 2, 50);
+
+ assertThat(page.getItems()).hasSize(50);
+ assertThat(page.getTotal()).isEqualTo(200);
+ assertThat(page.isResultMayBeTruncated()).isTrue();
+ }
}
diff --git a/web/src/api/message.test.ts b/web/src/api/message.test.ts
index f43c08854..7c7a651af 100644
--- a/web/src/api/message.test.ts
+++ b/web/src/api/message.test.ts
@@ -18,7 +18,7 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { getMessageTrace, queryMessages } from './message';
+import { getMessageTrace, queryMessagePage, queryMessages } from './message';
const mock = new MockAdapter(client);
@@ -87,6 +87,17 @@ describe('message API', () => {
]);
});
+ it('uses the paged query contract and preserves its truncation state', async
() => {
+ const params = { instanceId: 'instance-1', topic: 'orders', page: 2,
pageSize: 50 };
+ const page = { items: [], total: 200, page: 2, size: 50,
resultMayBeTruncated: true };
+ mock.onGet('/messages/page').reply((config) => {
+ expect(config.params).toEqual(params);
+ return [200, { code: 200, data: page }];
+ });
+
+ await expect(queryMessagePage(params)).resolves.toEqual(page);
+ });
+
it('unwraps trace records with numeric timestamps', async () => {
const trace = {
nodes: [
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index 9bc1cfa12..9762e886d 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -44,6 +44,14 @@ export interface MessageQuery {
endTime?: number;
}
+export interface MessageQueryPage {
+ items: MessageRecord[];
+ total: number;
+ page: number;
+ size: number;
+ resultMayBeTruncated: boolean;
+}
+
const toStoreTimestamp = (storeTime: MessageRecord['storeTime']): number => {
if (typeof storeTime === 'number') return storeTime;
@@ -87,6 +95,13 @@ export async function queryMessages(params: MessageQuery) {
return sortMessagesByStoreTimeDesc(res.data.data);
}
+export async function queryMessagePage(
+ params: MessageQuery & { page?: number; pageSize?: number },
+) {
+ const res = await client.get<{ data: MessageQueryPage }>('/messages/page', {
params });
+ return { ...res.data.data, items:
sortMessagesByStoreTimeDesc(res.data.data.items) };
+}
+
export async function getMessageTrace(msgId: string, instanceId?: string,
topic?: string) {
const params: Record<string, string> = {};
if (instanceId !== undefined) params.instanceId = instanceId;
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index b692192a3..f12750ddc 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { App, Modal } from 'antd';
+import { App, message, Modal } from 'antd';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
@@ -37,7 +37,17 @@ const instanceFilterMocks = vi.hoisted(() => ({
const QUERY_HISTORY_STORAGE_KEY = 'rocketmq-studio-message-query-history';
-vi.mock('../../../services/messageService', () => messageServiceMocks);
+vi.mock('../../../services/messageService', () => ({
+ ...messageServiceMocks,
+ queryMessagePage: ({ page = 1, pageSize = 50, ...params }: Record<string,
unknown>) =>
+ Promise.resolve(messageServiceMocks.queryMessages(params)).then((items) =>
({
+ items,
+ total: items.length,
+ page,
+ size: pageSize,
+ resultMayBeTruncated: false,
+ })),
+}));
vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
vi.mock('../../../services/instanceService', () => ({
@@ -256,14 +266,10 @@ describe('Message page query history', () => {
await user.click(screen.getByRole('button', { name: /最近查询/ }));
// Clearing requires confirmation: the dialog is commanded imperatively,
so spy on it
// and drive the confirm callback instead of depending on portal rendering
in jsdom.
- const confirmSpy = vi
- .spyOn(Modal, 'confirm')
- .mockImplementation((config) => {
- config.onOk?.();
- return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<
- typeof Modal.confirm
- >;
- });
+ const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
+ config.onOk?.();
+ return { destroy: vi.fn(), update: vi.fn() } as unknown as
ReturnType<typeof Modal.confirm>;
+ });
await user.click(await screen.findByText('清空历史'));
expect(confirmSpy).toHaveBeenCalled();
confirmSpy.mockRestore();
@@ -343,6 +349,40 @@ describe('Message page query history', () => {
]);
});
+ it('does not rewrite query history or repeat the success toast when
paginating', async () => {
+ const user = userEvent.setup();
+ const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
+ const historyWrites = () =>
+ setItemSpy.mock.calls.filter(([key]) => key ===
QUERY_HISTORY_STORAGE_KEY);
+ const successSpy = vi.spyOn(message,
'success').mockImplementation(vi.fn());
+ messageServiceMocks.queryMessages.mockResolvedValue(
+ Array.from({ length: 60 }, (_, index) => createMessage(`MID-PAGE-${index
+ 1}`)),
+ );
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'), 'MID-PAGE');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledTimes(1);
+ });
+ expect(historyWrites()).toHaveLength(1);
+ expect(successSpy).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getByTitle('2'));
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledTimes(2);
+ });
+
+ // Paging only navigates the result set: the explicit query is written to
history once and
+ // the success toast is not re-announced on every page change.
+ expect(historyWrites()).toHaveLength(1);
+ expect(successSpy).toHaveBeenCalledTimes(1);
+ });
+
it('replays topic and key queries with their saved parameters', async () => {
const user = userEvent.setup();
const topicParams = {
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 59bd3025f..3ee9c2bfa 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -32,7 +32,17 @@ const instanceFilterMocks = vi.hoisted(() => ({
useInstanceFilter: vi.fn(),
}));
-vi.mock('../../../services/messageService', () => serviceMocks);
+vi.mock('../../../services/messageService', () => ({
+ ...serviceMocks,
+ queryMessagePage: ({ page = 1, pageSize = 50, ...params }: Record<string,
unknown>) =>
+ Promise.resolve(serviceMocks.queryMessages(params)).then((items) => ({
+ items,
+ total: items.length,
+ page,
+ size: pageSize,
+ resultMayBeTruncated: false,
+ })),
+}));
vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
vi.mock('../../../services/instanceService', () => ({
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index b36d99e28..9d91efc31 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -56,7 +56,7 @@ import { InstanceSelect } from
'../../components/InstanceSelect';
import MessageQueryHistoryDrawer from
'../../components/MessageQueryHistoryDrawer';
import { useLang } from '../../i18n/LangContext';
import type { MessageQuery, MessageRecord, TraceRecord } from
'../../api/message';
-import { getMessageTrace, queryMessages } from '../../services/messageService';
+import { getMessageTrace, queryMessagePage } from
'../../services/messageService';
import { listTopics } from '../../services/topicService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { downloadBlob } from '../../utils/download';
@@ -312,6 +312,10 @@ const MessagePageContent = ({
const [keyInput, setKeyInput] = useState('');
const [msgIdInput, setMsgIdInput] = useState('');
const [messages, setMessages] = useState<MessageRecord[]>([]);
+ const [messageTotal, setMessageTotal] = useState(0);
+ const [messagePage, setMessagePage] = useState(1);
+ const [messagePageSize, setMessagePageSize] = useState(50);
+ const [resultMayBeTruncated, setResultMayBeTruncated] = useState(false);
const [queryLoading, setQueryLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [modalTab, setModalTab] = useState('content');
@@ -377,7 +381,13 @@ const MessagePageContent = ({
});
};
- const executeQuery = async (mode: QueryMode, params: MessageQuery) => {
+ const executeQuery = async (
+ mode: QueryMode,
+ params: MessageQuery,
+ page = 1,
+ pageSize = messagePageSize,
+ saveHistory = true,
+ ) => {
const requestGeneration = queryGenerationRef.current + 1;
queryGenerationRef.current = requestGeneration;
if (!selectedInstanceId) {
@@ -395,12 +405,23 @@ const MessagePageContent = ({
setQueryLoading(true);
setQueryError(null);
try {
- const result = await queryMessages({ ...normalizedParams, instanceId:
selectedInstanceId });
+ const result = await queryMessagePage({
+ ...normalizedParams,
+ instanceId: selectedInstanceId,
+ page,
+ pageSize,
+ });
if (queryGenerationRef.current !== requestGeneration) return;
- setMessages(result);
+ setMessages(result.items);
+ setMessageTotal(result.total);
+ setMessagePage(result.page);
+ setMessagePageSize(result.size);
+ setResultMayBeTruncated(result.resultMayBeTruncated);
setQueryError(null);
- saveRecentQuery(mode, normalizedParams);
- message.success(`查询完成,共 ${result.length} 条`);
+ if (saveHistory) {
+ saveRecentQuery(mode, normalizedParams);
+ message.success(`查询完成,共 ${result.total} 条`);
+ }
} catch (error) {
if (queryGenerationRef.current === requestGeneration) {
setQueryError(getErrorMessage(error, DEFAULT_QUERY_ERROR));
@@ -933,6 +954,14 @@ const MessagePageContent = ({
{queryError && (
<Alert showIcon type="warning" message={queryError} style={{
marginBottom: 16 }} />
)}
+ {resultMayBeTruncated && (
+ <Alert
+ showIcon
+ type="warning"
+ message="查询结果达到服务端扫描上限,当前总数可能不完整。"
+ style={{ marginBottom: 16 }}
+ />
+ )}
{/* ── Results Table ── */}
<Card styles={{ body: { padding: 0 } }}>
@@ -942,9 +971,13 @@ const MessagePageContent = ({
loading={queryLoading}
rowKey="msgId"
pagination={{
- pageSize: 50,
+ current: messagePage,
+ pageSize: messagePageSize,
+ total: messageTotal,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条消息`,
+ onChange: (page, pageSize) =>
+ void executeQuery(queryMode, currentQueryParams, page, pageSize,
false),
}}
size="small"
scroll={{ x: tableScrollX(columns) }}
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index a738c1281..ff9e0de50 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -3,6 +3,7 @@ import * as messageApi from '../api/message';
import { sortMessagesByStoreTimeDesc } from '../api/message';
import type {
MessageQuery,
+ MessageQueryPage,
MessageRecord,
TraceRecord,
DLQGroup,
@@ -49,6 +50,25 @@ export async function queryMessages(params: MessageQuery):
Promise<MessageRecord
return messageApi.queryMessages(params);
}
+export async function queryMessagePage(
+ params: MessageQuery & { page?: number; pageSize?: number },
+): Promise<MessageQueryPage> {
+ if (isMockMode()) {
+ const items = await queryMessages(params);
+ const page = params.page ?? 1;
+ const pageSize = params.pageSize ?? 50;
+ const from = Math.min((page - 1) * pageSize, items.length);
+ return {
+ items: items.slice(from, from + pageSize),
+ total: items.length,
+ page,
+ size: pageSize,
+ resultMayBeTruncated: false,
+ };
+ }
+ return messageApi.queryMessagePage(params);
+}
+
export async function getMessageTrace(
msgId: string,
instanceId?: string,
@@ -69,8 +89,7 @@ export async function listDLQGroups(
): Promise<DLQGroupPage> {
if (isMockMode()) {
const groups = (mockDLQGroups as unknown as DLQGroup[]).filter(
- (group) =>
- !search || group.groupName.includes(search) ||
group.dlqTopic.includes(search),
+ (group) => !search || group.groupName.includes(search) ||
group.dlqTopic.includes(search),
);
const from = Math.min((page - 1) * pageSize, groups.length);
return {