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 9994f0305 feat(message): add queue-based message browsing (#2545)
9994f0305 is described below
commit 9994f030593123609bba08656d07f61ec1342edf
Author: lizhimins <[email protected]>
AuthorDate: Mon Aug 24 15:00:36 2026 +0800
feat(message): add queue-based message browsing (#2545)
Add a new 'Browse by Queue' tab to the message query page, allowing
users to inspect messages by selecting a specific broker queue and
pulling from a given offset. Backend exposes queue offset listing and
offset-based message pull endpoints. Frontend introduces QueueBrowser
component with queue table and per-queue message pull panel.
Also fixes the scan-limit warning incorrectly showing in queue mode.
---
.../studio/instance/message/MessageController.java | 15 +
.../studio/instance/message/MessageProvider.java | 4 +
.../instance/message/MessageProviderStub.java | 13 +
.../studio/instance/message/MessageRecordVO.java | 3 +
.../studio/instance/message/MessageService.java | 22 +-
.../{MessageProvider.java => QueueOffsetVO.java} | 20 +-
web/src/api/message.ts | 29 ++
web/src/components/MessageQueryHistoryDrawer.tsx | 26 +-
web/src/components/QueueBrowser.tsx | 364 +++++++++++++++++
.../pages/instance/__tests__/MessagePage.test.tsx | 3 +
.../__tests__/MessagePageAsyncState.test.tsx | 3 +
web/src/pages/instance/message.tsx | 451 ++++++++-------------
12 files changed, 664 insertions(+), 289 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 586944ff8..314692cc8 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
@@ -62,4 +62,19 @@ public class MessageController {
@RequestParam(required =
false) String topic) {
return Result.ok(messageService.getMessageTrace(instanceId, msgId,
topic));
}
+
+ @GetMapping("/queues")
+ public Result<List<QueueOffsetVO>> getQueueOffsets(@RequestParam String
instanceId,
+ @RequestParam String
topic) {
+ return Result.ok(messageService.getQueueOffsets(instanceId, topic));
+ }
+
+ @GetMapping("/queue-message")
+ public Result<MessageRecordVO> pullMessageAtOffset(@RequestParam String
instanceId,
+ @RequestParam String
topic,
+ @RequestParam String
brokerName,
+ @RequestParam int
queueId,
+ @RequestParam long
offset) {
+ return Result.ok(messageService.pullMessageAtOffset(instanceId, topic,
brokerName, queueId, offset));
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
index c4cc365a3..09242f9fd 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
@@ -24,4 +24,8 @@ public interface MessageProvider {
Long endTime);
TraceRecordVO getMessageTrace(String instanceId, String msgId, String
topic);
+
+ List<QueueOffsetVO> getQueueOffsets(String instanceId, String topic);
+
+ MessageRecordVO pullMessageAtOffset(String instanceId, String topic,
String brokerName, int queueId, long offset);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProviderStub.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProviderStub.java
index 7b24291ba..a29a98ef7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProviderStub.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProviderStub.java
@@ -41,6 +41,19 @@ public class MessageProviderStub implements MessageProvider {
throw unsupported();
}
+ @Override
+ public List<QueueOffsetVO> getQueueOffsets(String instanceId, String
topic) {
+ log.warn("MessageProviderStub.getQueueOffsets called but no real
message provider is configured");
+ throw unsupported();
+ }
+
+ @Override
+ public MessageRecordVO pullMessageAtOffset(String instanceId, String
topic, String brokerName,
+ int queueId, long offset) {
+ log.warn("MessageProviderStub.pullMessageAtOffset called but no real
message provider is configured");
+ throw unsupported();
+ }
+
private BusinessException unsupported() {
return new BusinessException(501, "Message query provider is not
configured");
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
index 6908f916e..2dc53898e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
@@ -32,6 +32,9 @@ public class MessageRecordVO {
private String topic;
private String tag;
private String key;
+ private String brokerName;
+ private Integer queueId;
+ private Long queueOffset;
private String body;
private String bodyEncoding;
private boolean bodyTruncated;
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 fbeab1ab5..e707d7dd6 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,7 +31,7 @@ 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 MAX_PAGE_SIZE = 200;
private static final int TOPIC_QUERY_RESULT_LIMIT = 200;
private final MessageProvider messageProvider;
@@ -52,7 +52,7 @@ public class MessageService {
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");
+ throw new BusinessException(400, "page must be positive and
pageSize must be between 1 and 200");
}
List<MessageRecordVO> result = queryMessages(instanceId, topic, msgId,
tag, key, startTime, endTime);
long offset = (long) (page - 1) * pageSize;
@@ -76,6 +76,24 @@ public class MessageService {
return result;
}
+ public List<QueueOffsetVO> getQueueOffsets(String instanceId, String
topic) {
+ if (!StringUtils.hasText(topic)) {
+ throw new BusinessException(400, "topic is required");
+ }
+ return messageProvider.getQueueOffsets(instanceId, topic);
+ }
+
+ public MessageRecordVO pullMessageAtOffset(String instanceId, String
topic, String brokerName,
+ int queueId, long offset) {
+ if (!StringUtils.hasText(topic)) {
+ throw new BusinessException(400, "topic is required");
+ }
+ if (!StringUtils.hasText(brokerName)) {
+ throw new BusinessException(400, "brokerName is required");
+ }
+ return messageProvider.pullMessageAtOffset(instanceId, topic,
brokerName, queueId, offset);
+ }
+
private void recordMessageQuery(String instanceId, String topic, String
msgId, String tag,
String key, Long startTime, Long endTime,
int resultCount) {
String queryType = StringUtils.hasText(msgId) ? "MSG_ID" :
StringUtils.hasText(key) ? "KEY" : "TOPIC";
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueueOffsetVO.java
similarity index 72%
copy from
server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
copy to
server/src/main/java/org/apache/rocketmq/studio/instance/message/QueueOffsetVO.java
index c4cc365a3..c470b6236 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueueOffsetVO.java
@@ -16,12 +16,18 @@
*/
package org.apache.rocketmq.studio.instance.message;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
-import java.util.List;
-
-public interface MessageProvider {
- List<MessageRecordVO> queryMessages(String instanceId, String topic,
String msgId, String tag, String key, Long startTime,
- Long endTime);
-
- TraceRecordVO getMessageTrace(String instanceId, String msgId, String
topic);
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class QueueOffsetVO {
+ private String brokerName;
+ private int queueId;
+ private long minOffset;
+ private long maxOffset;
}
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index 9762e886d..27fa8b10e 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -6,6 +6,9 @@ export interface MessageRecord {
topic: string;
tag: string | null;
key: string | null;
+ brokerName: string | null;
+ queueId: number | null;
+ queueOffset: number | null;
body: string;
storeTime: number | string;
bornHost: string;
@@ -142,3 +145,29 @@ export async function exportDLQMessages(params: {
const res = await client.get<Blob>('/dlq/export', { params, responseType:
'blob' });
return res.data;
}
+
+// ─── Queue Browser ─────────────────────────────────────────────────
+export interface QueueOffset {
+ brokerName: string;
+ queueId: number;
+ minOffset: number;
+ maxOffset: number;
+}
+
+export async function getQueueOffsets(params: { instanceId: string; topic:
string }) {
+ const res = await client.get<{ data: QueueOffset[] }>('/messages/queues', {
params });
+ return res.data.data;
+}
+
+export async function pullMessageAtOffset(params: {
+ instanceId: string;
+ topic: string;
+ brokerName: string;
+ queueId: number;
+ offset: number;
+}) {
+ const res = await client.get<{ data: MessageRecord | null
}>('/messages/queue-message', {
+ params,
+ });
+ return res.data.data;
+}
diff --git a/web/src/components/MessageQueryHistoryDrawer.tsx
b/web/src/components/MessageQueryHistoryDrawer.tsx
index 846f243de..6914b28ce 100644
--- a/web/src/components/MessageQueryHistoryDrawer.tsx
+++ b/web/src/components/MessageQueryHistoryDrawer.tsx
@@ -20,6 +20,8 @@ interface Props {
open: boolean;
clusterId?: string;
onClose: () => void;
+ onSelectMessage?: (record: MessageQueryHistory) => void;
+ onSelectTrace?: (record: TraceQueryHistory) => void;
}
const PAGE_SIZE = 20;
@@ -29,7 +31,13 @@ const formatTime = (value?: string) => {
return Number.isNaN(timestamp.getTime()) ? '-' : timestamp.toLocaleString();
};
-const MessageQueryHistoryDrawer = ({ open, clusterId, onClose }: Props) => {
+const MessageQueryHistoryDrawer = ({
+ open,
+ clusterId,
+ onClose,
+ onSelectMessage,
+ onSelectTrace,
+}: Props) => {
const [tab, setTab] = useState<'messages' | 'traces'>('messages');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -154,6 +162,14 @@ const MessageQueryHistoryDrawer = ({ open, clusterId,
onClose }: Props) => {
columns={messageColumns}
dataSource={messageRows}
pagination={{ current: page, pageSize: PAGE_SIZE, total,
onChange: setPage }}
+ onRow={
+ onSelectMessage
+ ? (record) => ({
+ onClick: () => onSelectMessage(record),
+ style: { cursor: 'pointer' },
+ })
+ : undefined
+ }
/>
),
},
@@ -167,6 +183,14 @@ const MessageQueryHistoryDrawer = ({ open, clusterId,
onClose }: Props) => {
columns={traceColumns}
dataSource={traceRows}
pagination={{ current: page, pageSize: PAGE_SIZE, total,
onChange: setPage }}
+ onRow={
+ onSelectTrace
+ ? (record) => ({
+ onClick: () => onSelectTrace(record),
+ style: { cursor: 'pointer' },
+ })
+ : undefined
+ }
/>
),
},
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
new file mode 100644
index 000000000..367817670
--- /dev/null
+++ b/web/src/components/QueueBrowser.tsx
@@ -0,0 +1,364 @@
+/*
+ * 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.
+ */
+import { useCallback, useState } from 'react';
+import {
+ Button,
+ Card,
+ Descriptions,
+ Empty,
+ Flex,
+ Select,
+ Slider,
+ Space,
+ Spin,
+ Table,
+ Tag,
+ Tooltip,
+ Typography,
+ message,
+} from 'antd';
+import { CloseOutlined, SearchOutlined } from '@ant-design/icons';
+import type { MessageRecord, QueueOffset } from '../api/message';
+import { getQueueOffsets, pullMessageAtOffset } from '../api/message';
+
+const { Text, Paragraph } = Typography;
+
+export interface TopicOption {
+ label: string;
+ value: string;
+}
+
+const formatTimeMs = (value: number | string) => {
+ const ts = typeof value === 'string' ? new Date(value).getTime() : value;
+ if (!ts || Number.isNaN(ts)) return '-';
+ return new Date(ts).toLocaleString('zh-CN', { hour12: false });
+};
+
+export interface PulledEntry {
+ key: string;
+ offset: number;
+ message: MessageRecord | null;
+}
+
+export const useQueueBrowser = (instanceId?: string) => {
+ const [topic, setTopic] = useState<string | undefined>();
+ const [queues, setQueues] = useState<QueueOffset[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [offsets, setOffsets] = useState<Record<string, number>>({});
+ const [pulling, setPulling] = useState<string | null>(null);
+ const [entries, setEntries] = useState<PulledEntry[]>([]);
+
+ const loadQueues = useCallback(async () => {
+ if (!instanceId || !topic) return;
+ setLoading(true);
+ setQueues([]);
+ setOffsets({});
+ setEntries([]);
+ try {
+ const result = await getQueueOffsets({ instanceId, topic });
+ setQueues(result);
+ const initial: Record<string, number> = {};
+ for (const q of result) {
+ initial[`${q.brokerName}-${q.queueId}`] =
+ q.maxOffset > q.minOffset ? q.maxOffset - 1 : q.minOffset;
+ }
+ setOffsets(initial);
+ } catch (err) {
+ message.error(err instanceof Error ? err.message : '加载队列信息失败');
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId, topic]);
+
+ const handlePull = async (queue: QueueOffset) => {
+ if (!instanceId || !topic) return;
+ const key = `${queue.brokerName}-${queue.queueId}`;
+ const offset = offsets[key] ?? queue.minOffset;
+ setPulling(key);
+ try {
+ const msg = await pullMessageAtOffset({
+ instanceId,
+ topic,
+ brokerName: queue.brokerName,
+ queueId: queue.queueId,
+ offset,
+ });
+ setEntries((prev) => [
+ ...prev.filter((entry) => entry.key !== key),
+ { key, offset, message: msg },
+ ]);
+ } catch (err) {
+ message.error(err instanceof Error ? err.message : '拉取消息失败');
+ } finally {
+ setPulling(null);
+ }
+ };
+
+ const closeEntry = (key: string) => {
+ setEntries((prev) => prev.filter((entry) => entry.key !== key));
+ };
+
+ return {
+ topic,
+ setTopic,
+ queues,
+ loading,
+ offsets,
+ setOffsets,
+ pulling,
+ entries,
+ loadQueues,
+ handlePull,
+ closeEntry,
+ };
+};
+
+export type QueueBrowserState = ReturnType<typeof useQueueBrowser>;
+
+interface ControlsProps {
+ instanceId?: string;
+ state: QueueBrowserState;
+ topicOptions: TopicOption[];
+ topicLoading?: boolean;
+}
+
+export const QueueBrowserControls = ({
+ instanceId,
+ state,
+ topicOptions,
+ topicLoading,
+}: ControlsProps) => (
+ <Flex gap={12} align="center">
+ <Select
+ showSearch
+ allowClear
+ placeholder="选择 Topic"
+ value={state.topic}
+ onChange={state.setTopic}
+ options={topicOptions}
+ loading={topicLoading}
+ style={{ width: 280 }}
+ />
+ <Button
+ type="primary"
+ icon={<SearchOutlined />}
+ disabled={!instanceId || !state.topic}
+ loading={state.loading}
+ onClick={() => void state.loadQueues()}
+ >
+ 加载队列
+ </Button>
+ </Flex>
+);
+
+export const QueueBrowserResults = ({ state }: { state: QueueBrowserState })
=> (
+ <Card>
+ {state.loading ? (
+ <Flex justify="center" style={{ padding: 32 }}>
+ <Spin />
+ </Flex>
+ ) : state.queues.length === 0 ? (
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description="选择 Topic 并点击「加载队列」,按队列浏览消息"
+ style={{ padding: '32px 0' }}
+ />
+ ) : (
+ <Flex gap={16} align="flex-start">
+ {/* 左侧:队列表格 */}
+ <div style={{ width: '50%', flexShrink: 0 }}>
+ <Table<QueueOffset>
+ rowKey={(r) => `${r.brokerName}-${r.queueId}`}
+ dataSource={state.queues}
+ size="small"
+ pagination={false}
+ columns={[
+ {
+ title: 'Broker',
+ dataIndex: 'brokerName',
+ width: 180,
+ ellipsis: { showTitle: false },
+ render: (v: string) => (
+ <Tooltip title={v}>
+ <Text strong style={{ fontSize: 14 }}>
+ {v}
+ </Text>
+ </Tooltip>
+ ),
+ },
+ {
+ title: 'Queue',
+ dataIndex: 'queueId',
+ width: 50,
+ align: 'center',
+ render: (v: number) => <Text style={{ fontSize: 14
}}>{v}</Text>,
+ },
+ {
+ title: 'Offset 范围',
+ key: 'offset',
+ width: 170,
+ render: (_: unknown, record: QueueOffset) => {
+ const key = `${record.brokerName}-${record.queueId}`;
+ const currentOffset = state.offsets[key] ?? record.minOffset;
+ return (
+ <Flex align="center" gap={8}>
+ <Text type="secondary" style={{ fontSize: 14,
flexShrink: 0 }}>
+ {record.minOffset}
+ </Text>
+ <Slider
+ style={{ flex: 1, margin: 0 }}
+ min={record.minOffset}
+ max={
+ record.maxOffset > record.minOffset
+ ? record.maxOffset - 1
+ : record.minOffset
+ }
+ value={currentOffset}
+ onChange={(value) =>
+ state.setOffsets((prev) => ({ ...prev, [key]: value
}))
+ }
+ tooltip={{ formatter: (v) => `offset: ${v}` }}
+ />
+ <Text code style={{ fontSize: 14, flexShrink: 0 }}>
+ {currentOffset}
+ </Text>
+ </Flex>
+ );
+ },
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 70,
+ align: 'center',
+ render: (_: unknown, record: QueueOffset) => {
+ const key = `${record.brokerName}-${record.queueId}`;
+ return (
+ <Button
+ size="small"
+ type="primary"
+ loading={state.pulling === key}
+ onClick={() => void state.handlePull(record)}
+ >
+ 查看
+ </Button>
+ );
+ },
+ },
+ ]}
+ />
+ <Text type="secondary" style={{ display: 'block', marginTop: 8,
fontSize: 14 }}>
+ 共 {state.queues.length} 个队列,总消息量{' '}
+ {state.queues.reduce((sum, q) => sum + (q.maxOffset -
q.minOffset), 0)} 条
+ </Text>
+ </div>
+
+ {/* 右侧:消息详情(2 列,可多条并存) */}
+ <div style={{ flex: 1, minWidth: 0 }}>
+ {state.entries.length === 0 ? (
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description="点击左侧「查看」,消息详情将显示在这里"
+ style={{ padding: '32px 0' }}
+ />
+ ) : (
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: 12 }}>
+ {state.entries.map((entry) => (
+ <Card
+ key={entry.key}
+ size="small"
+ style={{ borderRadius: 8 }}
+ title={
+ <Space size={4}>
+ <Tag color="blue" style={{ marginInlineEnd: 0 }}>
+ {entry.key}
+ </Tag>
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ @ {entry.offset}
+ </Text>
+ </Space>
+ }
+ extra={
+ <Button
+ type="text"
+ size="small"
+ icon={<CloseOutlined />}
+ onClick={() => state.closeEntry(entry.key)}
+ />
+ }
+ >
+ {entry.message ? (
+ <>
+ <Descriptions column={1} size="small">
+ <Descriptions.Item label="Message ID">
+ <Paragraph
+ copyable
+ style={{ marginBottom: 0, fontFamily: 'monospace',
fontSize: 14 }}
+ >
+ {entry.message.msgId}
+ </Paragraph>
+ </Descriptions.Item>
+ <Descriptions.Item label="Tag">
+ <Tag>{entry.message.tag || '-'}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="Key">
+ <span style={{ fontFamily: 'monospace', fontSize: 14
}}>
+ {entry.message.key || '-'}
+ </span>
+ </Descriptions.Item>
+ <Descriptions.Item label="存储时间">
+ <span style={{ fontFamily: 'monospace', fontSize: 14
}}>
+ {formatTimeMs(entry.message.storeTime)}
+ </span>
+ </Descriptions.Item>
+ <Descriptions.Item label="大小">
+ {entry.message.size} bytes
+ </Descriptions.Item>
+ <Descriptions.Item label="Born Host">
+ {entry.message.bornHost || '-'}
+ </Descriptions.Item>
+ </Descriptions>
+ {entry.message.body && (
+ <Card size="small" title="Body" style={{ marginTop: 12
}}>
+ <pre
+ style={{
+ maxHeight: 160,
+ overflow: 'auto',
+ fontSize: 14,
+ fontFamily: 'monospace',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-all',
+ margin: 0,
+ }}
+ >
+ {entry.message.body}
+ </pre>
+ </Card>
+ )}
+ </>
+ ) : (
+ <Text type="secondary">该 offset 处无消息</Text>
+ )}
+ </Card>
+ ))}
+ </div>
+ )}
+ </div>
+ </Flex>
+ )}
+ </Card>
+);
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index f12750ddc..3a0e259eb 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -62,6 +62,9 @@ const createMessage = (msgId: string): MessageRecord => ({
topic: `topic-${msgId}`,
tag: 'tag',
key: `key-${msgId}`,
+ brokerName: 'broker-a',
+ queueId: 0,
+ queueOffset: 0,
body: '{}',
storeTime: '2026-07-31T00:00:00Z',
bornHost: '127.0.0.1:1000',
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 3ee9c2bfa..8f7ae5192 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -83,6 +83,9 @@ const createMessage = (msgId: string): MessageRecord => ({
topic: `topic-${msgId}`,
tag: 'tag',
key: `key-${msgId}`,
+ brokerName: 'broker-a',
+ queueId: 0,
+ queueOffset: 0,
body: '{}',
storeTime: '2026-07-31T00:00:00Z',
bornHost: '127.0.0.1:1000',
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 9d91efc31..d74cb2292 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -33,10 +33,8 @@ import {
Input,
Space,
Flex,
- Dropdown,
message,
} from 'antd';
-import type { MenuProps } from 'antd';
import {
SearchOutlined,
ReloadOutlined,
@@ -46,7 +44,6 @@ import {
CheckCircleOutlined,
DownloadOutlined,
HistoryOutlined,
- DeleteOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
@@ -54,6 +51,12 @@ import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
import { InstanceSelect } from '../../components/InstanceSelect';
import MessageQueryHistoryDrawer from
'../../components/MessageQueryHistoryDrawer';
+import {
+ useQueueBrowser,
+ QueueBrowserControls,
+ QueueBrowserResults,
+} from '../../components/QueueBrowser';
+import type { MessageQueryHistory, TraceQueryHistory } from
'../../api/messageHistory';
import { useLang } from '../../i18n/LangContext';
import type { MessageQuery, MessageRecord, TraceRecord } from
'../../api/message';
import { getMessageTrace, queryMessagePage } from
'../../services/messageService';
@@ -69,12 +72,7 @@ const DEFAULT_TRACE_ERROR = '消息轨迹加载失败,请稍后重试';
/* ─── Constants ─── */
-type QueryMode = 'topic' | 'key' | 'msgid';
-
-type RecentQuery = {
- mode: QueryMode;
- params: MessageQuery;
-};
+type QueryMode = 'topic' | 'key' | 'msgid' | 'queue';
type ApiErrorLike = {
message?: unknown;
@@ -85,14 +83,13 @@ type ApiErrorLike = {
};
};
-const QUERY_HISTORY_STORAGE_KEY = 'rocketmq-studio-message-query-history';
-const MAX_QUERY_HISTORY = 5;
const RESEND_UNAVAILABLE_MESSAGE = '当前版本尚未接入普通消息重新发送接口';
const QUERY_OPTIONS = [
{ value: 'topic' as const, label: '按 Topic 查询' },
{ value: 'key' as const, label: '按 Message Key' },
{ value: 'msgid' as const, label: '按 Message ID' },
+ { value: 'queue' as const, label: '按队列浏览' },
];
const DELIVERY_STATUS_MAP: Record<string, { label: string; color: string }> = {
@@ -135,28 +132,6 @@ const formatBody = (body: string): string => {
}
};
-const isQueryMode = (value: unknown): value is QueryMode =>
- value === 'topic' || value === 'key' || value === 'msgid';
-
-const isOptionalString = (value: unknown): value is string | undefined =>
- value === undefined || typeof value === 'string';
-
-const isOptionalTimestamp = (value: unknown): value is number | undefined =>
- value === undefined || (typeof value === 'number' && Number.isFinite(value));
-
-const isMessageQuery = (value: unknown): value is MessageQuery => {
- if (typeof value !== 'object' || value === null || Array.isArray(value))
return false;
- const params = value as MessageQuery;
- return (
- isOptionalString(params.topic) &&
- isOptionalString(params.tag) &&
- isOptionalString(params.key) &&
- isOptionalString(params.msgId) &&
- isOptionalTimestamp(params.startTime) &&
- isOptionalTimestamp(params.endTime)
- );
-};
-
const getQueryValidationError = (mode: QueryMode, params: MessageQuery):
string | null => {
if (!params.topic?.trim()) return '请选择 Topic';
if (mode === 'key' && !params.key?.trim()) return '请输入 Message Key';
@@ -191,42 +166,6 @@ const normalizeMessageQuery = (mode: QueryMode, params:
MessageQuery): MessageQu
return commonParams;
};
-const isRecentQuery = (value: unknown): value is RecentQuery => {
- if (typeof value !== 'object' || value === null) return false;
- const query = value as RecentQuery;
- return (
- isQueryMode(query.mode) &&
- isMessageQuery(query.params) &&
- getQueryValidationError(query.mode, normalizeMessageQuery(query.mode,
query.params)) === null
- );
-};
-
-const loadRecentQueries = (): RecentQuery[] => {
- try {
- const stored = localStorage.getItem(QUERY_HISTORY_STORAGE_KEY);
- if (!stored) return [];
- const parsed: unknown = JSON.parse(stored);
- if (!Array.isArray(parsed)) return [];
- return parsed
- .filter(isRecentQuery)
- .map(({ mode, params }) => ({ mode, params: normalizeMessageQuery(mode,
params) }))
- .slice(0, MAX_QUERY_HISTORY);
- } catch {
- return [];
- }
-};
-
-const querySignature = (query: RecentQuery): string => JSON.stringify(query);
-
-const queryLabel = ({ mode, params }: RecentQuery): string => {
- if (mode === 'msgid')
- return `Message ID: ${params.msgId || '全部'} · Topic: ${params.topic ||
'全部'}`;
- if (mode === 'key') {
- return `Key: ${params.key || '全部'}${params.topic ? ` · Topic:
${params.topic}` : ''}`;
- }
- return `Topic: ${params.topic || '全部'}`;
-};
-
const getErrorMessage = (error: unknown, fallback: string): string => {
const apiError = error as ApiErrorLike;
const responseMessage = apiError.response?.data?.message;
@@ -307,6 +246,7 @@ const MessagePageContent = ({
};
}, [loadTopicOptions]);
const [queryMode, setQueryMode] = useState<QueryMode>('topic');
+ const queueBrowser = useQueueBrowser(selectedInstanceId);
const [selectedTopic, setSelectedTopic] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(getDefaultRange);
const [keyInput, setKeyInput] = useState('');
@@ -324,7 +264,6 @@ const MessagePageContent = ({
const [traceLoading, setTraceLoading] = useState(false);
const [queryError, setQueryError] = useState<string | null>(null);
const [traceError, setTraceError] = useState<string | null>(null);
- const [recentQueries, setRecentQueries] =
useState<RecentQuery[]>(loadRecentQueries);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const queryGenerationRef = useRef(0);
const traceGenerationRef = useRef(0);
@@ -364,29 +303,11 @@ const MessagePageContent = ({
setQueryLoading(false);
};
- const saveRecentQuery = (mode: QueryMode, params: MessageQuery) => {
- const nextQuery = { mode, params };
- const signature = querySignature(nextQuery);
- setRecentQueries((current) => {
- const next = [
- nextQuery,
- ...current.filter((item) => querySignature(item) !== signature),
- ].slice(0, MAX_QUERY_HISTORY);
- try {
- localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, JSON.stringify(next));
- } catch {
- // Query history remains available for the current session when
storage is unavailable.
- }
- return next;
- });
- };
-
const executeQuery = async (
mode: QueryMode,
params: MessageQuery,
page = 1,
pageSize = messagePageSize,
- saveHistory = true,
) => {
const requestGeneration = queryGenerationRef.current + 1;
queryGenerationRef.current = requestGeneration;
@@ -418,10 +339,7 @@ const MessagePageContent = ({
setMessagePageSize(result.size);
setResultMayBeTruncated(result.resultMayBeTruncated);
setQueryError(null);
- if (saveHistory) {
- saveRecentQuery(mode, normalizedParams);
- message.success(`查询完成,共 ${result.total} 条`);
- }
+ message.success(`查询完成,共 ${result.total} 条`);
} catch (error) {
if (queryGenerationRef.current === requestGeneration) {
setQueryError(getErrorMessage(error, DEFAULT_QUERY_ERROR));
@@ -437,66 +355,33 @@ const MessagePageContent = ({
await executeQuery(queryMode, currentQueryParams);
};
- const replayRecentQuery = (recentQuery: RecentQuery) => {
- const { mode, params } = recentQuery;
+ const replayHistoryRecord = (record: MessageQueryHistory) => {
+ const modeMap: Record<string, QueryMode> = { TOPIC: 'topic', KEY: 'key',
MSG_ID: 'msgid' };
+ const mode = modeMap[record.queryType] || 'topic';
+ const params: MessageQuery = {
+ topic: record.topic,
+ msgId: record.msgId || undefined,
+ key: record.messageKey || undefined,
+ startTime: record.startTime,
+ endTime: record.endTime,
+ };
setQueryMode(mode);
- setSelectedTopic(params.topic);
- setKeyInput(params.key || '');
- setMsgIdInput(params.msgId || '');
- if (mode === 'topic' && params.startTime !== undefined && params.endTime
!== undefined) {
- setDateRange([dayjs(params.startTime), dayjs(params.endTime)]);
+ setSelectedTopic(record.topic);
+ setKeyInput(record.messageKey || '');
+ setMsgIdInput(record.msgId || '');
+ if (mode === 'topic' && record.startTime !== undefined && record.endTime
!== undefined) {
+ setDateRange([dayjs(record.startTime), dayjs(record.endTime)]);
}
+ setHistoryDrawerOpen(false);
void executeQuery(mode, params);
};
- const clearRecentQueries = () => {
- setRecentQueries([]);
- try {
- localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
- } catch {
- // Ignore storage failures after clearing the in-memory history.
- }
- };
-
- const recentQueryMenuItems: MenuProps['items'] = [
- ...recentQueries.map((recentQuery, index) => {
- const label = queryLabel(recentQuery);
- return {
- key: String(index),
- label: (
- <Text ellipsis={{ tooltip: label }} style={{ maxWidth: 360 }}>
- {label}
- </Text>
- ),
- };
- }),
- ...(recentQueries.length > 0
- ? [
- { type: 'divider' as const },
- {
- key: 'clear',
- danger: true,
- icon: <DeleteOutlined />,
- label: '清空历史',
- },
- ]
- : []),
- ];
-
- const handleRecentQueryMenuClick: MenuProps['onClick'] = ({ key }) => {
- if (key === 'clear') {
- Modal.confirm({
- title: '清空查询历史',
- content: '确定要清空全部查询历史吗?此操作不可恢复。',
- okText: '清空',
- okType: 'danger',
- cancelText: '取消',
- onOk: clearRecentQueries,
- });
- return;
- }
- const recentQuery = recentQueries[Number(key)];
- if (recentQuery) replayRecentQuery(recentQuery);
+ const replayTraceRecord = (record: TraceQueryHistory) => {
+ setQueryMode('msgid');
+ setSelectedTopic(record.topic);
+ setMsgIdInput(record.msgId);
+ setHistoryDrawerOpen(false);
+ void executeQuery('msgid', { topic: record.topic, msgId: record.msgId });
};
const handleVerifyConsume = () => {
@@ -547,6 +432,7 @@ const MessagePageContent = ({
dataIndex: 'topic',
key: 'topic',
width: 170,
+ ellipsis: true,
sorter: (a, b) => a.topic.localeCompare(b.topic),
render: (topic: string) => (
<Text strong style={{ fontSize: 14 }}>
@@ -559,7 +445,6 @@ const MessagePageContent = ({
dataIndex: 'tag',
key: 'tag',
width: 80,
- sorter: (a, b) => (a.tag ?? '').localeCompare(b.tag ?? ''),
render: (tag: string | null) => <Tag>{tag || '-'}</Tag>,
},
{
@@ -567,7 +452,7 @@ const MessagePageContent = ({
dataIndex: 'key',
key: 'key',
width: 120,
- sorter: (a, b) => (a.key ?? '').localeCompare(b.key ?? ''),
+ ellipsis: true,
render: (key: string | null) => (
<span style={{ fontFamily: 'monospace', fontSize: 14 }}>{key ||
'-'}</span>
),
@@ -576,11 +461,15 @@ const MessagePageContent = ({
title: 'Message ID',
dataIndex: 'msgId',
key: 'msgId',
- sorter: (a, b) => a.msgId.localeCompare(b.msgId),
+ width: 260,
render: (id: string) => (
- <Paragraph copyable style={{ fontSize: 14, marginBottom: 0,
fontFamily: 'monospace' }}>
+ <Text
+ copyable={{ text: id }}
+ ellipsis={{ tooltip: id }}
+ style={{ fontSize: 14, fontFamily: 'monospace', width: '100%',
display: 'block' }}
+ >
{id}
- </Paragraph>
+ </Text>
),
},
{
@@ -601,7 +490,6 @@ const MessagePageContent = ({
key: 'size',
width: 80,
align: 'right',
- sorter: (a, b) => a.size - b.size,
render: (size: number) => formatSize(size),
},
{
@@ -818,119 +706,120 @@ const MessagePageContent = ({
/>
</Space>
- <Space wrap size={12}>
- {queryMode === 'topic' && (
- <>
- <Select
- placeholder="选择 Topic"
- style={{ width: 360 }}
- value={selectedTopic}
- onChange={setSelectedTopic}
- allowClear
- showSearch
- loading={topicLoading}
- disabled={topicLoading || Boolean(topicError)}
- options={topicOptions.map((t) => ({
- value: t,
- label: t,
- }))}
- />
- <RangePicker
- showTime
- style={{ width: 400 }}
- value={dateRange}
- onChange={(vals) => {
- if (vals && vals[0] && vals[1]) {
- setDateRange([vals[0], vals[1]]);
- }
- }}
- />
- </>
- )}
-
- {queryMode === 'key' && (
- <>
- <Select
- placeholder="选择 Topic"
- style={{ width: 360 }}
- value={selectedTopic}
- onChange={setSelectedTopic}
- allowClear
- showSearch
- loading={topicLoading}
- disabled={topicLoading || Boolean(topicError)}
- options={topicOptions.map((t) => ({
- value: t,
- label: t,
- }))}
- />
- <Input
- placeholder="输入 Message Key"
- style={{ width: 240 }}
- value={keyInput}
- onChange={(e) => setKeyInput(e.target.value)}
- />
- </>
- )}
-
- {queryMode === 'msgid' && (
- <>
- <Select
- placeholder="选择 Topic"
- style={{ width: 360 }}
- value={selectedTopic}
- onChange={setSelectedTopic}
- allowClear
- showSearch
- loading={topicLoading}
- disabled={topicLoading || Boolean(topicError)}
- options={topicOptions.map((t) => ({
- value: t,
- label: t,
- }))}
- />
- <Input
- placeholder="输入 Message ID"
- style={{ width: 400 }}
- value={msgIdInput}
- onChange={(e) => setMsgIdInput(e.target.value)}
- />
- </>
- )}
+ {queryMode !== 'queue' && (
+ <Space wrap size={12}>
+ {queryMode === 'topic' && (
+ <>
+ <Select
+ placeholder="选择 Topic"
+ style={{ width: 360 }}
+ value={selectedTopic}
+ onChange={setSelectedTopic}
+ allowClear
+ showSearch
+ loading={topicLoading}
+ disabled={topicLoading || Boolean(topicError)}
+ options={topicOptions.map((t) => ({
+ value: t,
+ label: t,
+ }))}
+ />
+ <RangePicker
+ showTime
+ style={{ width: 400 }}
+ value={dateRange}
+ onChange={(vals) => {
+ if (vals && vals[0] && vals[1]) {
+ setDateRange([vals[0], vals[1]]);
+ }
+ }}
+ />
+ </>
+ )}
+
+ {queryMode === 'key' && (
+ <>
+ <Select
+ placeholder="选择 Topic"
+ style={{ width: 360 }}
+ value={selectedTopic}
+ onChange={setSelectedTopic}
+ allowClear
+ showSearch
+ loading={topicLoading}
+ disabled={topicLoading || Boolean(topicError)}
+ options={topicOptions.map((t) => ({
+ value: t,
+ label: t,
+ }))}
+ />
+ <Input
+ placeholder="输入 Message Key"
+ style={{ width: 240 }}
+ value={keyInput}
+ onChange={(e) => setKeyInput(e.target.value)}
+ />
+ </>
+ )}
+
+ {queryMode === 'msgid' && (
+ <>
+ <Select
+ placeholder="选择 Topic"
+ style={{ width: 360 }}
+ value={selectedTopic}
+ onChange={setSelectedTopic}
+ allowClear
+ showSearch
+ loading={topicLoading}
+ disabled={topicLoading || Boolean(topicError)}
+ options={topicOptions.map((t) => ({
+ value: t,
+ label: t,
+ }))}
+ />
+ <Input
+ placeholder="输入 Message ID"
+ style={{ width: 400 }}
+ value={msgIdInput}
+ onChange={(e) => setMsgIdInput(e.target.value)}
+ />
+ </>
+ )}
- <Button
- type="primary"
- icon={<SearchOutlined />}
- disabled={Boolean(queryDisabledReason)}
- title={queryDisabledReason || undefined}
- onClick={() => {
- void handleQuery();
- }}
- >
- 查询
- </Button>
- <Dropdown
- menu={{ items: recentQueryMenuItems, onClick:
handleRecentQueryMenuClick }}
- trigger={['click']}
- disabled={recentQueries.length === 0 || !selectedInstanceId}
- >
<Button
- icon={<HistoryOutlined />}
- disabled={recentQueries.length === 0 || !selectedInstanceId}
+ type="primary"
+ icon={<SearchOutlined />}
+ disabled={Boolean(queryDisabledReason)}
+ title={queryDisabledReason || undefined}
+ onClick={() => {
+ void handleQuery();
+ }}
>
- 最近查询
+ 查询
</Button>
- </Dropdown>
- <Button icon={<ReloadOutlined />} onClick={handleReset}>
- 重置
- </Button>
- <Button icon={<HistoryOutlined />} onClick={() =>
setHistoryDrawerOpen(true)}>
- 服务端历史
- </Button>
- </Space>
+ <Button icon={<ReloadOutlined />} onClick={handleReset}>
+ 重置
+ </Button>
+ <Button icon={<HistoryOutlined />} onClick={() =>
setHistoryDrawerOpen(true)}>
+ 服务端历史
+ </Button>
+ </Space>
+ )}
+
+ {queryMode === 'queue' && (
+ <QueueBrowserControls
+ instanceId={selectedInstanceId}
+ state={queueBrowser}
+ topicOptions={topicOptions.map((t) => ({ label: t, value: t }))}
+ topicLoading={topicLoading}
+ />
+ )}
</Space>
</Card>
+ {queryMode === 'queue' && <QueueBrowserResults state={queueBrowser} />}
+
{topicError && (
<Alert
showIcon
@@ -949,12 +838,14 @@ const MessagePageContent = ({
open={historyDrawerOpen}
clusterId={selectedInstanceId}
onClose={() => setHistoryDrawerOpen(false)}
+ onSelectMessage={replayHistoryRecord}
+ onSelectTrace={replayTraceRecord}
/>
{queryError && (
<Alert showIcon type="warning" message={queryError} style={{
marginBottom: 16 }} />
)}
- {resultMayBeTruncated && (
+ {queryMode !== 'queue' && resultMayBeTruncated && (
<Alert
showIcon
type="warning"
@@ -964,25 +855,27 @@ const MessagePageContent = ({
)}
{/* ── Results Table ── */}
- <Card styles={{ body: { padding: 0 } }}>
- <Table
- columns={columns}
- dataSource={messages}
- loading={queryLoading}
- rowKey="msgId"
- pagination={{
- 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) }}
- />
- </Card>
+ {queryMode !== 'queue' && (
+ <Card styles={{ body: { padding: 0 } }}>
+ <Table
+ columns={columns}
+ dataSource={messages}
+ loading={queryLoading}
+ rowKey="msgId"
+ pagination={{
+ current: messagePage,
+ pageSize: messagePageSize,
+ total: messageTotal,
+ showSizeChanger: true,
+ showTotal: (total) => `共 ${total} 条消息`,
+ onChange: (page, pageSize) =>
+ void executeQuery(queryMode, currentQueryParams, page,
pageSize),
+ }}
+ size="small"
+ scroll={{ x: tableScrollX(columns) }}
+ />
+ </Card>
+ )}
{/* ── Message Detail Modal ── */}
<Modal