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 4142cb96 feat(messages): expose persisted query history (#2113)
4142cb96 is described below

commit 4142cb96da28c4e5190738832053a4cf69448275
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 13 19:17:10 2026 +0800

    feat(messages): expose persisted query history (#2113)
    
    Add server-side message and trace query history with pagination, search
    and summaries, plus a searchable history drawer on the message page.
    Search terms are LIKE-escaped so wildcards match literally. Rebased onto
    current rocketmq-studio (InstanceSelect / topicError coexist).
    
    Co-authored-by: Yue Wang <[email protected]>
---
 .../instance/message/MessageQueryHistoryVO.java    |  29 ++++
 .../instance/message/QueryHistoryController.java   |  60 ++++++++
 .../instance/message/QueryHistoryService.java      | 100 +++++++++++++
 .../instance/message/QueryHistorySummaryVO.java    |  20 +++
 .../instance/message/TraceQueryHistoryVO.java      |  25 ++++
 .../message/QueryHistoryControllerTest.java        |  76 ++++++++++
 .../instance/message/QueryHistoryServiceTest.java  |  49 +++++++
 web/src/api/messageHistory.test.ts                 |  57 ++++++++
 web/src/api/messageHistory.ts                      |  80 +++++++++++
 web/src/components/MessageQueryHistoryDrawer.tsx   | 158 +++++++++++++++++++++
 .../__tests__/MessageQueryHistoryDrawer.test.tsx   |  91 ++++++++++++
 web/src/pages/instance/message.tsx                 |  10 ++
 12 files changed, 755 insertions(+)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageQueryHistoryVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageQueryHistoryVO.java
new file mode 100644
index 00000000..931239c1
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageQueryHistoryVO.java
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class MessageQueryHistoryVO {
+    private Long id;
+    private String queryType;
+    private String topic;
+    private String msgId;
+    private String tag;
+    private String messageKey;
+    private Long startTime;
+    private Long endTime;
+    private int resultCount;
+    private String clusterId;
+    private String queriedBy;
+    private LocalDateTime queriedAt;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryController.java
new file mode 100644
index 00000000..d5ed62d2
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryController.java
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/query-history")
+@RequiredArgsConstructor
+public class QueryHistoryController {
+    private static final int MAX_PAGE_SIZE = 100;
+    private final QueryHistoryService queryHistoryService;
+
+    @GetMapping("/messages")
+    public Result<PageResult<MessageQueryHistoryVO>> listMessageQueries(
+            @RequestParam(required = false) String clusterId,
+            @RequestParam(required = false) String queryType,
+            @RequestParam(required = false) String search,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        validatePage(page, pageSize);
+        return Result.ok(queryHistoryService.listMessageQueries(
+                clusterId, queryType, search, page, pageSize));
+    }
+
+    @GetMapping("/traces")
+    public Result<PageResult<TraceQueryHistoryVO>> listTraceQueries(
+            @RequestParam(required = false) String clusterId,
+            @RequestParam(required = false) String search,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        validatePage(page, pageSize);
+        return Result.ok(queryHistoryService.listTraceQueries(clusterId, 
search, page, pageSize));
+    }
+
+    @GetMapping("/summary")
+    public Result<QueryHistorySummaryVO> summary(@RequestParam(required = 
false) String clusterId) {
+        return Result.ok(queryHistoryService.summarize(clusterId));
+    }
+
+    private void validatePage(int page, int pageSize) {
+        if (page < 1) {
+            throw new BusinessException(400, "page must be at least 1");
+        }
+        if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+            throw new BusinessException(400, "pageSize must be between 1 and " 
+ MAX_PAGE_SIZE);
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
index 68b56d1c..0db51126 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
@@ -17,6 +17,8 @@
 package org.apache.rocketmq.studio.instance.message;
 
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
@@ -24,11 +26,14 @@ import 
org.apache.rocketmq.studio.persistence.entity.RmqMessageQuery;
 import org.apache.rocketmq.studio.persistence.entity.RmqTraceQuery;
 import org.apache.rocketmq.studio.persistence.mapper.RmqMessageQueryMapper;
 import org.apache.rocketmq.studio.persistence.mapper.RmqTraceQueryMapper;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 
 import java.time.Clock;
 import java.time.LocalDateTime;
+import java.util.List;
 
 @Slf4j
 @Service
@@ -88,6 +93,65 @@ public class QueryHistoryService {
         log.debug("Trace query recorded: clusterId={} msgId={} topic={}", 
clusterId, msgId, topic);
     }
 
+    public PageResult<MessageQueryHistoryVO> listMessageQueries(String 
clusterId, String queryType,
+                                                                 String 
search, int page, int pageSize) {
+        String pattern = escapeLike(search);
+        QueryWrapper<RmqMessageQuery> query = new 
QueryWrapper<RmqMessageQuery>()
+                .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId)
+                .eq(StringUtils.hasText(queryType), "query_type", queryType)
+                .and(StringUtils.hasText(search), nested -> nested
+                        .like("topic", pattern)
+                        .or().like("msg_id", pattern)
+                        .or().like("message_key", pattern)
+                        .or().like("queried_by", pattern))
+                .orderByDesc("queried_at", "id");
+        Page<RmqMessageQuery> result = messageQueryMapper.selectPage(new 
Page<>(page, pageSize), query);
+        List<MessageQueryHistoryVO> items = result.getRecords().stream()
+                .map(QueryHistoryService::toMessageHistory).toList();
+        return PageResult.of(items, result.getTotal(), page, pageSize);
+    }
+
+    public PageResult<TraceQueryHistoryVO> listTraceQueries(String clusterId, 
String search,
+                                                             int page, int 
pageSize) {
+        String pattern = escapeLike(search);
+        QueryWrapper<RmqTraceQuery> query = new QueryWrapper<RmqTraceQuery>()
+                .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId)
+                .and(StringUtils.hasText(search), nested -> nested
+                        .like("topic", pattern)
+                        .or().like("msg_id", pattern)
+                        .or().like("queried_by", pattern))
+                .orderByDesc("queried_at", "id");
+        Page<RmqTraceQuery> result = traceQueryMapper.selectPage(new 
Page<>(page, pageSize), query);
+        List<TraceQueryHistoryVO> items = result.getRecords().stream()
+                .map(QueryHistoryService::toTraceHistory).toList();
+        return PageResult.of(items, result.getTotal(), page, pageSize);
+    }
+
+    public QueryHistorySummaryVO summarize(String clusterId) {
+        QueryWrapper<RmqMessageQuery> messageFilter = new 
QueryWrapper<RmqMessageQuery>()
+                .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId);
+        QueryWrapper<RmqTraceQuery> traceFilter = new 
QueryWrapper<RmqTraceQuery>()
+                .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId);
+        long messageCount = messageQueryMapper.selectCount(messageFilter);
+        long traceCount = traceQueryMapper.selectCount(traceFilter);
+        RmqMessageQuery latestMessage = messageQueryMapper.selectOne(
+                new QueryWrapper<RmqMessageQuery>()
+                        .eq(StringUtils.hasText(clusterId), "cluster_id", 
clusterId)
+                        .orderByDesc("queried_at", "id").last("LIMIT 1"));
+        RmqTraceQuery latestTrace = traceQueryMapper.selectOne(
+                new QueryWrapper<RmqTraceQuery>()
+                        .eq(StringUtils.hasText(clusterId), "cluster_id", 
clusterId)
+                        .orderByDesc("queried_at", "id").last("LIMIT 1"));
+        LocalDateTime latest = latestOf(
+                latestMessage == null ? null : latestMessage.getQueriedAt(),
+                latestTrace == null ? null : latestTrace.getQueriedAt());
+        return QueryHistorySummaryVO.builder()
+                .messageQueries(messageCount)
+                .traceQueries(traceCount)
+                .latestQueryAt(latest)
+                .build();
+    }
+
     @Scheduled(fixedDelayString = 
"${studio.query-history.cleanup-interval:PT24H}")
     public void purgeExpiredQueries() {
         int retentionDays = properties.getRetentionDays();
@@ -119,4 +183,40 @@ public class QueryHistoryService {
             log.warn("Failed to purge expired trace query records: {}", 
e.getMessage());
         }
     }
+
+    private static LocalDateTime latestOf(LocalDateTime left, LocalDateTime 
right) {
+        if (left == null) return right;
+        if (right == null) return left;
+        return left.isAfter(right) ? left : right;
+    }
+
+    private static MessageQueryHistoryVO toMessageHistory(RmqMessageQuery 
query) {
+        return MessageQueryHistoryVO.builder()
+                
.id(query.getId()).queryType(query.getQueryType()).topic(query.getTopic())
+                
.msgId(query.getMsgId()).tag(query.getTag()).messageKey(query.getMessageKey())
+                .startTime(query.getStartTime()).endTime(query.getEndTime())
+                .resultCount(query.getResultCount() == null ? 0 : 
query.getResultCount())
+                
.clusterId(query.getClusterId()).queriedBy(query.getQueriedBy())
+                .queriedAt(query.getQueriedAt()).build();
+    }
+
+    private static TraceQueryHistoryVO toTraceHistory(RmqTraceQuery query) {
+        return TraceQueryHistoryVO.builder()
+                
.id(query.getId()).msgId(query.getMsgId()).topic(query.getTopic())
+                .nodeCount(query.getNodeCount() == null ? 0 : 
query.getNodeCount())
+                .consumerCount(query.getConsumerCount() == null ? 0 : 
query.getConsumerCount())
+                
.clusterId(query.getClusterId()).queriedBy(query.getQueriedBy())
+                .queriedAt(query.getQueriedAt()).build();
+    }
+
+    /**
+     * Escapes LIKE wildcards so user-supplied search terms match literally 
instead of being
+     * interpreted as {@code %}/{@code _} patterns.
+     */
+    private static String escapeLike(String search) {
+        if (!StringUtils.hasText(search)) {
+            return search;
+        }
+        return search.replace("\\", "\\\\").replace("%", "\\%").replace("_", 
"\\_");
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySummaryVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySummaryVO.java
new file mode 100644
index 00000000..5a27c3c0
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySummaryVO.java
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class QueryHistorySummaryVO {
+    private long messageQueries;
+    private long traceQueries;
+    private LocalDateTime latestQueryAt;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
new file mode 100644
index 00000000..68413f6e
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class TraceQueryHistoryVO {
+    private Long id;
+    private String msgId;
+    private String topic;
+    private int nodeCount;
+    private int consumerCount;
+    private String clusterId;
+    private String queriedBy;
+    private LocalDateTime queriedAt;
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryControllerTest.java
new file mode 100644
index 00000000..f9fb7ae3
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryControllerTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import 
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(QueryHistoryController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class QueryHistoryControllerTest {
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockBean
+    private QueryHistoryService queryHistoryService;
+
+    @Test
+    void listsFilteredMessageHistory() throws Exception {
+        MessageQueryHistoryVO item = MessageQueryHistoryVO.builder()
+                .id(1L).queryType("TOPIC").topic("orders").resultCount(2)
+                .clusterId("instance-a").queriedBy("alice")
+                .queriedAt(LocalDateTime.of(2026, 8, 5, 12, 0)).build();
+        when(queryHistoryService.listMessageQueries("instance-a", "TOPIC", 
"orders", 2, 10))
+                .thenReturn(PageResult.of(List.of(item), 11, 2, 10));
+
+        mockMvc.perform(get("/api/query-history/messages")
+                        .param("clusterId", "instance-a")
+                        .param("queryType", "TOPIC")
+                        .param("search", "orders")
+                        .param("page", "2")
+                        .param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(11))
+                .andExpect(jsonPath("$.data.items[0].topic").value("orders"));
+
+        verify(queryHistoryService).listMessageQueries("instance-a", "TOPIC", 
"orders", 2, 10);
+    }
+
+    @Test
+    void rejectsOversizedHistoryPages() throws Exception {
+        mockMvc.perform(get("/api/query-history/traces").param("pageSize", 
"101"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value("pageSize must be 
between 1 and 100"));
+    }
+
+    @Test
+    void returnsHistorySummary() throws Exception {
+        when(queryHistoryService.summarize("instance-a"))
+                .thenReturn(QueryHistorySummaryVO.builder()
+                        .messageQueries(7).traceQueries(3)
+                        .latestQueryAt(LocalDateTime.of(2026, 8, 5, 12, 
0)).build());
+
+        mockMvc.perform(get("/api/query-history/summary").param("clusterId", 
"instance-a"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.messageQueries").value(7))
+                .andExpect(jsonPath("$.data.traceQueries").value(3));
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
index b9108bc4..8d8e821e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
@@ -17,6 +17,8 @@
 package org.apache.rocketmq.studio.instance.message;
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.persistence.entity.RmqMessageQuery;
 import org.apache.rocketmq.studio.persistence.entity.RmqTraceQuery;
@@ -102,4 +104,51 @@ class QueryHistoryServiceTest {
         verify(messageQueryMapper, never()).delete(any());
         verify(traceQueryMapper, never()).delete(any());
     }
+
+    @Test
+    void listsMessageHistoryAsNewestFirstPage() {
+        RmqMessageQuery entity = new RmqMessageQuery();
+        entity.setId(9L);
+        entity.setQueryType("KEY");
+        entity.setTopic("orders");
+        entity.setMessageKey("order-1");
+        entity.setResultCount(3);
+        entity.setClusterId("cluster-a");
+        entity.setQueriedBy("alice");
+        entity.setQueriedAt(LocalDateTime.of(2026, 8, 5, 12, 0));
+        when(messageQueryMapper.selectPage(any(Page.class), 
any(Wrapper.class)))
+                .thenAnswer(invocation -> {
+                    Page<RmqMessageQuery> result = invocation.getArgument(0);
+                    result.setRecords(java.util.List.of(entity));
+                    result.setTotal(1);
+                    return result;
+                });
+
+        PageResult<MessageQueryHistoryVO> result = service.listMessageQueries(
+                "cluster-a", "KEY", "order", 1, 20);
+
+        assertThat(result.getTotal()).isEqualTo(1);
+        assertThat(result.getItems()).singleElement().satisfies(item -> {
+            assertThat(item.getMessageKey()).isEqualTo("order-1");
+            assertThat(item.getQueriedBy()).isEqualTo("alice");
+        });
+    }
+
+    @Test
+    void summarizesBothHistoryStreams() {
+        RmqMessageQuery message = new RmqMessageQuery();
+        message.setQueriedAt(LocalDateTime.of(2026, 8, 5, 10, 0));
+        RmqTraceQuery trace = new RmqTraceQuery();
+        trace.setQueriedAt(LocalDateTime.of(2026, 8, 5, 12, 0));
+        when(messageQueryMapper.selectCount(any())).thenReturn(7L);
+        when(traceQueryMapper.selectCount(any())).thenReturn(4L);
+        when(messageQueryMapper.selectOne(any())).thenReturn(message);
+        when(traceQueryMapper.selectOne(any())).thenReturn(trace);
+
+        QueryHistorySummaryVO summary = service.summarize("cluster-a");
+
+        assertThat(summary.getMessageQueries()).isEqualTo(7);
+        assertThat(summary.getTraceQueries()).isEqualTo(4);
+        
assertThat(summary.getLatestQueryAt()).isEqualTo(LocalDateTime.of(2026, 8, 5, 
12, 0));
+    }
 }
diff --git a/web/src/api/messageHistory.test.ts 
b/web/src/api/messageHistory.test.ts
new file mode 100644
index 00000000..540c7b0c
--- /dev/null
+++ b/web/src/api/messageHistory.test.ts
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import client from './client';
+import {
+  getQueryHistorySummary,
+  listMessageQueryHistory,
+  listTraceQueryHistory,
+} from './messageHistory';
+
+const mock = new MockAdapter(client);
+
+describe('message query history API', () => {
+  beforeEach(() => mock.reset());
+  afterEach(() => mock.reset());
+
+  it('forwards filters and pagination for message history', async () => {
+    mock.onGet('/query-history/messages').reply((config) => {
+      expect(config.params).toEqual({
+        clusterId: 'instance-a',
+        search: 'orders',
+        page: 2,
+        pageSize: 20,
+      });
+      return [200, { code: 200, data: { items: [], total: 0, page: 2, size: 20 
} }];
+    });
+
+    const result = await listMessageQueryHistory({
+      clusterId: 'instance-a',
+      search: 'orders',
+      page: 2,
+      pageSize: 20,
+    });
+    expect(result.page).toBe(2);
+  });
+
+  it('loads trace history and summary for an instance', async () => {
+    mock.onGet('/query-history/traces').reply(200, {
+      code: 200,
+      data: { items: [{ id: 1, msgId: 'msg-1' }], total: 1, page: 1, size: 20 
},
+    });
+    mock.onGet('/query-history/summary').reply((config) => {
+      expect(config.params).toEqual({ clusterId: 'instance-a' });
+      return [200, { code: 200, data: { messageQueries: 3, traceQueries: 1 } 
}];
+    });
+
+    await expect(listTraceQueryHistory({ clusterId: 'instance-a' 
})).resolves.toMatchObject({
+      total: 1,
+    });
+    await 
expect(getQueryHistorySummary('instance-a')).resolves.toMatchObject({ 
traceQueries: 1 });
+  });
+});
diff --git a/web/src/api/messageHistory.ts b/web/src/api/messageHistory.ts
new file mode 100644
index 00000000..a48abe5e
--- /dev/null
+++ b/web/src/api/messageHistory.ts
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+import client from './client';
+
+export interface HistoryPage<T> {
+  items: T[];
+  total: number;
+  page: number;
+  size: number;
+}
+
+export interface MessageQueryHistory {
+  id: number;
+  queryType: 'TOPIC' | 'KEY' | 'MSG_ID';
+  topic?: string;
+  msgId?: string;
+  tag?: string;
+  messageKey?: string;
+  startTime?: number;
+  endTime?: number;
+  resultCount: number;
+  clusterId?: string;
+  queriedBy?: string;
+  queriedAt: string;
+}
+
+export interface TraceQueryHistory {
+  id: number;
+  msgId: string;
+  topic?: string;
+  nodeCount: number;
+  consumerCount: number;
+  clusterId?: string;
+  queriedBy?: string;
+  queriedAt: string;
+}
+
+export interface QueryHistorySummary {
+  messageQueries: number;
+  traceQueries: number;
+  latestQueryAt?: string;
+}
+
+export async function listMessageQueryHistory(params: {
+  clusterId?: string;
+  queryType?: string;
+  search?: string;
+  page?: number;
+  pageSize?: number;
+}) {
+  const response = await client.get<{ data: HistoryPage<MessageQueryHistory> 
}>(
+    '/query-history/messages',
+    { params },
+  );
+  return response.data.data;
+}
+
+export async function listTraceQueryHistory(params: {
+  clusterId?: string;
+  search?: string;
+  page?: number;
+  pageSize?: number;
+}) {
+  const response = await client.get<{ data: HistoryPage<TraceQueryHistory> }>(
+    '/query-history/traces',
+    { params },
+  );
+  return response.data.data;
+}
+
+export async function getQueryHistorySummary(clusterId?: string) {
+  const response = await client.get<{ data: QueryHistorySummary 
}>('/query-history/summary', {
+    params: clusterId ? { clusterId } : undefined,
+  });
+  return response.data.data;
+}
diff --git a/web/src/components/MessageQueryHistoryDrawer.tsx 
b/web/src/components/MessageQueryHistoryDrawer.tsx
new file mode 100644
index 00000000..ca5e2f5b
--- /dev/null
+++ b/web/src/components/MessageQueryHistoryDrawer.tsx
@@ -0,0 +1,158 @@
+/*
+ * 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.
+ */
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Alert, Drawer, Flex, Input, Statistic, Table, Tabs, Tag } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import {
+  getQueryHistorySummary,
+  listMessageQueryHistory,
+  listTraceQueryHistory,
+  type MessageQueryHistory,
+  type QueryHistorySummary,
+  type TraceQueryHistory,
+} from '../api/messageHistory';
+
+interface Props {
+  open: boolean;
+  clusterId?: string;
+  onClose: () => void;
+}
+
+const PAGE_SIZE = 20;
+const formatTime = (value?: string) => (value ? new 
Date(value).toLocaleString() : '-');
+
+const MessageQueryHistoryDrawer = ({ open, clusterId, onClose }: Props) => {
+  const [tab, setTab] = useState<'messages' | 'traces'>('messages');
+  const [search, setSearch] = useState('');
+  const [page, setPage] = useState(1);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState('');
+  const [summary, setSummary] = useState<QueryHistorySummary>();
+  const [messageRows, setMessageRows] = useState<MessageQueryHistory[]>([]);
+  const [traceRows, setTraceRows] = useState<TraceQueryHistory[]>([]);
+  const [total, setTotal] = useState(0);
+  const requestId = useRef(0);
+
+  const load = useCallback(async () => {
+    if (!open) return;
+    const id = ++requestId.current;
+    setLoading(true);
+    setError('');
+    try {
+      const [nextSummary, result] = await Promise.all([
+        getQueryHistorySummary(clusterId),
+        tab === 'messages'
+          ? listMessageQueryHistory({
+              clusterId,
+              search: search || undefined,
+              page,
+              pageSize: PAGE_SIZE,
+            })
+          : listTraceQueryHistory({
+              clusterId,
+              search: search || undefined,
+              page,
+              pageSize: PAGE_SIZE,
+            }),
+      ]);
+      if (id !== requestId.current) return;
+      setSummary(nextSummary);
+      setTotal(result.total);
+      if (tab === 'messages') setMessageRows(result.items as 
MessageQueryHistory[]);
+      else setTraceRows(result.items as TraceQueryHistory[]);
+    } catch (loadError) {
+      if (id === requestId.current) {
+        setError(loadError instanceof Error ? loadError.message : '查询历史加载失败');
+      }
+    } finally {
+      if (id === requestId.current) setLoading(false);
+    }
+  }, [clusterId, open, page, search, tab]);
+
+  useEffect(() => {
+    // Loading is asynchronous; state updates happen after the history API 
resolves.
+    // eslint-disable-next-line react-hooks/set-state-in-effect
+    void load();
+    return () => {
+      requestId.current += 1;
+    };
+  }, [load]);
+
+  const messageColumns: ColumnsType<MessageQueryHistory> = [
+    { title: '类型', dataIndex: 'queryType', width: 90, render: (value) => 
<Tag>{value}</Tag> },
+    { title: 'Topic', dataIndex: 'topic', ellipsis: true },
+    { title: 'Message ID / Key', render: (_, row) => row.msgId || 
row.messageKey || '-' },
+    { title: '结果数', dataIndex: 'resultCount', width: 80 },
+    { title: '操作者', dataIndex: 'queriedBy', width: 110 },
+    { title: '查询时间', dataIndex: 'queriedAt', width: 180, render: formatTime },
+  ];
+  const traceColumns: ColumnsType<TraceQueryHistory> = [
+    { title: 'Message ID', dataIndex: 'msgId', ellipsis: true },
+    { title: 'Topic', dataIndex: 'topic', ellipsis: true },
+    { title: '轨迹节点', dataIndex: 'nodeCount', width: 90 },
+    { title: '消费者', dataIndex: 'consumerCount', width: 90 },
+    { title: '操作者', dataIndex: 'queriedBy', width: 110 },
+    { title: '查询时间', dataIndex: 'queriedAt', width: 180, render: formatTime },
+  ];
+
+  return (
+    <Drawer title="服务端查询历史" width={900} open={open} onClose={onClose} 
destroyOnHidden>
+      <Flex gap={32} style={{ marginBottom: 16 }}>
+        <Statistic title="消息查询" value={summary?.messageQueries ?? 0} />
+        <Statistic title="轨迹查询" value={summary?.traceQueries ?? 0} />
+        <Statistic title="最近查询" value={formatTime(summary?.latestQueryAt)} />
+      </Flex>
+      <Input.Search
+        allowClear
+        placeholder="搜索 Topic、Message ID、Key 或操作者"
+        onSearch={(value) => {
+          setPage(1);
+          setSearch(value.trim());
+        }}
+        style={{ marginBottom: 12, width: 420 }}
+      />
+      {error && <Alert type="error" showIcon message={error} style={{ 
marginBottom: 12 }} />}
+      <Tabs
+        activeKey={tab}
+        onChange={(key) => {
+          setPage(1);
+          setTab(key as 'messages' | 'traces');
+        }}
+        items={[
+          {
+            key: 'messages',
+            label: '消息查询',
+            children: (
+              <Table
+                rowKey="id"
+                loading={loading}
+                columns={messageColumns}
+                dataSource={messageRows}
+                pagination={{ current: page, pageSize: PAGE_SIZE, total, 
onChange: setPage }}
+              />
+            ),
+          },
+          {
+            key: 'traces',
+            label: '轨迹查询',
+            children: (
+              <Table
+                rowKey="id"
+                loading={loading}
+                columns={traceColumns}
+                dataSource={traceRows}
+                pagination={{ current: page, pageSize: PAGE_SIZE, total, 
onChange: setPage }}
+              />
+            ),
+          },
+        ]}
+      />
+    </Drawer>
+  );
+};
+
+export default MessageQueryHistoryDrawer;
diff --git a/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx 
b/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx
new file mode 100644
index 00000000..5d6cfa0a
--- /dev/null
+++ b/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import MessageQueryHistoryDrawer from '../MessageQueryHistoryDrawer';
+import {
+  getQueryHistorySummary,
+  listMessageQueryHistory,
+  listTraceQueryHistory,
+} from '../../api/messageHistory';
+
+vi.mock('../../api/messageHistory', () => ({
+  getQueryHistorySummary: vi.fn(),
+  listMessageQueryHistory: vi.fn(),
+  listTraceQueryHistory: vi.fn(),
+}));
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    value: vi.fn().mockImplementation(() => ({
+      matches: false,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+describe('MessageQueryHistoryDrawer', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(getQueryHistorySummary).mockResolvedValue({ messageQueries: 4, 
traceQueries: 2 });
+    vi.mocked(listMessageQueryHistory).mockResolvedValue({
+      items: [
+        {
+          id: 1,
+          queryType: 'KEY',
+          topic: 'orders',
+          messageKey: 'order-1',
+          resultCount: 2,
+          queriedBy: 'alice',
+          queriedAt: '2026-08-05T12:00:00Z',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+    vi.mocked(listTraceQueryHistory).mockResolvedValue({
+      items: [
+        {
+          id: 2,
+          msgId: 'msg-1',
+          topic: 'orders',
+          nodeCount: 3,
+          consumerCount: 1,
+          queriedBy: 'bob',
+          queriedAt: '2026-08-05T12:00:00Z',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+  });
+
+  it('loads persisted message and trace history by instance', async () => {
+    const user = userEvent.setup();
+    render(
+      <App>
+        <MessageQueryHistoryDrawer open clusterId="instance-a" 
onClose={vi.fn()} />
+      </App>,
+    );
+
+    expect(await screen.findByText('order-1')).toBeInTheDocument();
+    expect(listMessageQueryHistory).toHaveBeenCalledWith(
+      expect.objectContaining({ clusterId: 'instance-a' }),
+    );
+    await user.click(screen.getByRole('tab', { name: '轨迹查询' }));
+    expect(await screen.findByText('msg-1')).toBeInTheDocument();
+    await waitFor(() => expect(listTraceQueryHistory).toHaveBeenCalled());
+  });
+});
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 857f7ce2..02212af8 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -53,6 +53,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import PageHeader from '../../components/PageHeader';
 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';
@@ -307,6 +308,7 @@ const MessagePageContent = ({
   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);
 
@@ -869,6 +871,9 @@ const MessagePageContent = ({
             <Button icon={<ReloadOutlined />} onClick={handleReset}>
               重置
             </Button>
+            <Button icon={<HistoryOutlined />} onClick={() => 
setHistoryDrawerOpen(true)}>
+              服务端历史
+            </Button>
           </Space>
         </Space>
       </Card>
@@ -876,6 +881,11 @@ const MessagePageContent = ({
       {topicError && (
         <Alert showIcon type="error" message={topicError} style={{ 
marginBottom: 16 }} />
       )}
+      <MessageQueryHistoryDrawer
+        open={historyDrawerOpen}
+        clusterId={selectedInstanceId || undefined}
+        onClose={() => setHistoryDrawerOpen(false)}
+      />
 
       {queryError && (
         <Alert showIcon type="warning" message={queryError} style={{ 
marginBottom: 16 }} />

Reply via email to