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 9e772250 fix: scale audit log queries and preserve audit context (#964)
9e772250 is described below

commit 9e772250fcb6e9b67b358cdb97c4c4aba3283ddb
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 5 02:36:58 2026 -0700

    fix: scale audit log queries and preserve audit context (#964)
---
 .../rocketmq/studio/ops/audit/AuditRecordVO.java   |   4 +-
 .../rocketmq/studio/ops/audit/AuditRepository.java |   8 +-
 .../rocketmq/studio/ops/audit/AuditService.java    |  40 ++--
 .../ops/audit/MybatisPlusAuditRepository.java      |  21 +-
 .../rocketmq/studio/persistence/MyBatisConfig.java |  10 +
 .../studio/ops/audit/AuditServiceTest.java         | 257 ++++-----------------
 .../ops/audit/MybatisPlusAuditRepositoryTest.java  |  80 +++++++
 web/src/api/ops.test.ts                            |   4 +-
 web/src/api/ops.ts                                 |   4 +-
 web/src/i18n/translations.ts                       |   4 +-
 web/src/pages/ops/__tests__/AuditPage.test.tsx     |   4 +-
 web/src/pages/ops/audit.tsx                        |  22 +-
 web/src/services/opsService.test.ts                |  16 +-
 web/src/services/opsService.ts                     |   7 +-
 14 files changed, 226 insertions(+), 255 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRecordVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRecordVO.java
index 28ec1345..3ae8f2b0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRecordVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRecordVO.java
@@ -34,8 +34,10 @@ public class AuditRecordVO extends BaseEntity {
     private LocalDateTime timestamp;
     private String operator;
     private String operationType;
+    private String resourceType;
     private String target;
+    private String clusterId;
     private String detail;
-    private String ipAddress;
     private String result;
+    private String errorMessage;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRepository.java
index efb36d66..6bd5e1da 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRepository.java
@@ -18,12 +18,12 @@ package org.apache.rocketmq.studio.ops.audit;
 
 
 import java.time.LocalDateTime;
-import java.util.List;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 
 public interface AuditRepository {
-    List<AuditRecordVO> findAll(String search, String operationType,
-                              LocalDateTime startDate, LocalDateTime endDate,
-                              String result);
+    PageResult<AuditRecordVO> findPage(String search, String operationType,
+                                       LocalDateTime startDate, LocalDateTime 
endDate,
+                                       String result, int page, int pageSize);
 
     void save(AuditRecordVO record);
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
index 4a1f057d..47f79512 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
@@ -27,15 +27,16 @@ import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
-import java.util.List;
 
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class AuditService {
 
+    private static final int MAX_PAGE_SIZE = 100;
+    private static final int MAX_EXPORT_RECORDS = 10_000;
     private static final String CSV_HEADER =
-            
"timestamp,operator,operationType,target,detail,ipAddress,result\r\n";
+            
"timestamp,operator,operationType,resourceType,target,clusterId,detail,result,errorMessage\r\n";
 
     private final AuditRepository auditRepository;
 
@@ -47,30 +48,29 @@ public class AuditService {
         log.info("Querying audit logs, page={}, pageSize={}, search={}, 
operationType={}, result={}",
                 page, pageSize, search, operationType, result);
 
-        List<AuditRecordVO> allRecords = findRecords(search, operationType, 
startDate, endDate, result);
-        long total = allRecords.size();
-
-        long offset = (long) (page - 1) * pageSize;
-        int fromIndex = (int) Math.min(offset, allRecords.size());
-        int toIndex = (int) Math.min((long) fromIndex + pageSize, 
allRecords.size());
-        List<AuditRecordVO> pageRecords = allRecords.subList(fromIndex, 
toIndex);
-
-        return PageResult.of(pageRecords, total, page, pageSize);
+        return findPage(search, operationType, startDate, endDate, result, 
page, pageSize);
     }
 
     public String exportLogs(String search, String operationType, String 
startDate,
                              String endDate, String result) {
-        List<AuditRecordVO> records = findRecords(search, operationType, 
startDate, endDate, result);
+        PageResult<AuditRecordVO> page = findPage(
+                search, operationType, startDate, endDate, result, 1, 
MAX_EXPORT_RECORDS);
+        if (page.getTotal() > MAX_EXPORT_RECORDS) {
+            throw new BusinessException(400,
+                    "Audit log export exceeds the maximum of " + 
MAX_EXPORT_RECORDS + " records; narrow the filters");
+        }
         StringBuilder csv = new StringBuilder("\uFEFF").append(CSV_HEADER);
-        for (AuditRecordVO record : records) {
+        for (AuditRecordVO record : page.getItems()) {
             appendCsvRow(csv,
                     record.getTimestamp(),
                     record.getOperator(),
                     record.getOperationType(),
+                    record.getResourceType(),
                     record.getTarget(),
+                    record.getClusterId(),
                     record.getDetail(),
-                    record.getIpAddress(),
-                    record.getResult());
+                    record.getResult(),
+                    record.getErrorMessage());
         }
         return csv.toString();
     }
@@ -101,19 +101,19 @@ public class AuditService {
         if (page <= 0) {
             throw new BusinessException(400, "page must be greater than 0");
         }
-        if (pageSize <= 0) {
-            throw new BusinessException(400, "pageSize must be greater than 
0");
+        if (pageSize <= 0 || pageSize > MAX_PAGE_SIZE) {
+            throw new BusinessException(400, "pageSize must be between 1 and " 
+ MAX_PAGE_SIZE);
         }
     }
 
-    private List<AuditRecordVO> findRecords(String search, String 
operationType, String startDate,
-                                            String endDate, String result) {
+    private PageResult<AuditRecordVO> findPage(String search, String 
operationType, String startDate,
+                                               String endDate, String result, 
int page, int pageSize) {
         LocalDateTime start = parseDate(startDate, true, "startDate");
         LocalDateTime end = parseDate(endDate, false, "endDate");
         if (start != null && end != null && start.isAfter(end)) {
             throw new BusinessException(400, "startDate must not be after 
endDate");
         }
-        return auditRepository.findAll(search, operationType, start, end, 
result);
+        return auditRepository.findPage(search, operationType, start, end, 
result, page, pageSize);
     }
 
     private void appendCsvRow(StringBuilder csv, Object... values) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
index 5640d95d..9d2f2451 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
@@ -17,6 +17,8 @@
 package org.apache.rocketmq.studio.ops.audit;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.persistence.entity.RmqOperationAudit;
 import org.apache.rocketmq.studio.persistence.mapper.RmqOperationAuditMapper;
 import org.springframework.stereotype.Repository;
@@ -26,10 +28,7 @@ import java.time.LocalDateTime;
 import java.util.List;
 import java.util.stream.Collectors;
 
-/**
- * MySQL-backed audit repository (rmq_operation_audit). The VO's ipAddress has
- * no dedicated column and is not persisted.
- */
+/** MySQL-backed audit repository (rmq_operation_audit). */
 @Repository
 public class MybatisPlusAuditRepository implements AuditRepository {
 
@@ -40,9 +39,9 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
     }
 
     @Override
-    public List<AuditRecordVO> findAll(String search, String operationType,
-                                       LocalDateTime startDate, LocalDateTime 
endDate,
-                                       String result) {
+    public PageResult<AuditRecordVO> findPage(String search, String 
operationType,
+                                              LocalDateTime startDate, 
LocalDateTime endDate,
+                                              String result, int page, int 
pageSize) {
         QueryWrapper<RmqOperationAudit> query = new 
QueryWrapper<RmqOperationAudit>()
                 .and(StringUtils.hasText(search), w -> w
                         .like("operator", search)
@@ -53,9 +52,12 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
                 .le(endDate != null, "operated_at", endDate)
                 .eq(StringUtils.hasText(result), "result", result)
                 .orderByDesc("operated_at");
-        return auditMapper.selectList(query).stream()
+        Page<RmqOperationAudit> resultPage = auditMapper.selectPage(
+                new Page<>(page, pageSize), query);
+        List<AuditRecordVO> records = resultPage.getRecords().stream()
                 .map(MybatisPlusAuditRepository::toVO)
                 .collect(Collectors.toList());
+        return PageResult.of(records, resultPage.getTotal(), page, pageSize);
     }
 
     @Override
@@ -83,9 +85,12 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
         vo.setTimestamp(entity.getOperatedAt());
         vo.setOperator(entity.getOperator());
         vo.setOperationType(entity.getOperation());
+        vo.setResourceType(entity.getResourceType());
         vo.setTarget(entity.getResourceName());
+        vo.setClusterId(entity.getClusterId());
         vo.setDetail(entity.getDetail());
         vo.setResult(entity.getResult());
+        vo.setErrorMessage(entity.getErrorMessage());
         return vo;
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/MyBatisConfig.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/MyBatisConfig.java
index 002a5e8b..45ef5fb5 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/MyBatisConfig.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/MyBatisConfig.java
@@ -16,10 +16,20 @@
  */
 package org.apache.rocketmq.studio.persistence;
 
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import 
com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
 import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
 @Configuration
 @MapperScan("org.apache.rocketmq.studio.persistence.mapper")
 public class MyBatisConfig {
+
+    @Bean
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
+        return interceptor;
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
index 9b45bdfe..7cd76090 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
@@ -26,8 +26,6 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.time.LocalDateTime;
-import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -48,242 +46,89 @@ class AuditServiceTest {
     private AuditService auditService;
 
     @Test
-    void queryLogsShouldReturnFirstPage() {
-        AuditRecordVO r1 = AuditRecordVO.builder()
-                
.operator("admin").operationType("CREATE").target("topic-a").result("SUCCESS").build();
-        AuditRecordVO r2 = AuditRecordVO.builder()
-                
.operator("admin").operationType("DELETE").target("topic-b").result("SUCCESS").build();
-        AuditRecordVO r3 = AuditRecordVO.builder()
-                
.operator("user1").operationType("UPDATE").target("topic-c").result("FAILURE").build();
-        when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), 
isNull()))
-                .thenReturn(Arrays.asList(r1, r2, r3));
-
-        PageResult<AuditRecordVO> result = auditService.queryLogs(1, 2, null, 
null, null, null, null);
-
-        assertThat(result.getItems()).hasSize(2);
-        assertThat(result.getTotal()).isEqualTo(3);
-        assertThat(result.getPage()).isEqualTo(1);
-        assertThat(result.getSize()).isEqualTo(2);
-        
assertThat(result.getItems().get(0).getOperationType()).isEqualTo("CREATE");
-        
assertThat(result.getItems().get(1).getOperationType()).isEqualTo("DELETE");
-    }
-
-    @Test
-    void queryLogsShouldReturnSecondPage() {
-        AuditRecordVO r1 = 
AuditRecordVO.builder().operationType("CREATE").build();
-        AuditRecordVO r2 = 
AuditRecordVO.builder().operationType("DELETE").build();
-        AuditRecordVO r3 = 
AuditRecordVO.builder().operationType("UPDATE").build();
-        when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), 
isNull()))
-                .thenReturn(Arrays.asList(r1, r2, r3));
-
-        PageResult<AuditRecordVO> result = auditService.queryLogs(2, 2, null, 
null, null, null, null);
-
-        assertThat(result.getItems()).hasSize(1);
-        assertThat(result.getTotal()).isEqualTo(3);
-        assertThat(result.getPage()).isEqualTo(2);
-        
assertThat(result.getItems().get(0).getOperationType()).isEqualTo("UPDATE");
-    }
-
-    @Test
-    void queryLogsShouldReturnEmptyPageWhenNoRecords() {
-        when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), 
isNull()))
-                .thenReturn(Collections.emptyList());
-
-        PageResult<AuditRecordVO> result = auditService.queryLogs(1, 10, null, 
null, null, null, null);
-
-        assertThat(result.getItems()).isEmpty();
-        assertThat(result.getTotal()).isZero();
-        assertThat(result.getPage()).isEqualTo(1);
-        assertThat(result.getSize()).isEqualTo(10);
-    }
-
-    @Test
-    void queryLogsShouldReturnEmptyWhenPageExceedsTotal() {
-        AuditRecordVO r1 = 
AuditRecordVO.builder().operationType("CREATE").build();
-        when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), 
isNull()))
-                .thenReturn(List.of(r1));
-
-        PageResult<AuditRecordVO> result = auditService.queryLogs(5, 10, null, 
null, null, null, null);
-
-        assertThat(result.getItems()).isEmpty();
-        assertThat(result.getTotal()).isEqualTo(1);
-    }
-
-    @Test
-    void queryLogsShouldRejectNonPositivePage() {
-        assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null, 
null, null, null))
-                .isInstanceOf(BusinessException.class)
-                .hasMessage("page must be greater than 0")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
-    }
-
-    @Test
-    void queryLogsShouldRejectNonPositivePageSize() {
-        assertThatThrownBy(() -> auditService.queryLogs(1, 0, null, null, 
null, null, null))
-                .isInstanceOf(BusinessException.class)
-                .hasMessage("pageSize must be greater than 0")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
-    }
-
-    @Test
-    void queryLogsShouldAvoidOffsetOverflow() {
+    void queryLogsDelegatesPaginationAndFiltersToRepository() {
         AuditRecordVO record = 
AuditRecordVO.builder().operationType("CREATE").build();
-        when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), 
isNull()))
-                .thenReturn(List.of(record));
+        when(auditRepository.findPage(eq("topic-a"), eq("CREATE"), isNull(), 
isNull(), eq("SUCCESS"),
+                eq(2), eq(20))).thenReturn(PageResult.of(List.of(record), 21, 
2, 20));
 
         PageResult<AuditRecordVO> result = auditService.queryLogs(
-                Integer.MAX_VALUE, Integer.MAX_VALUE, null, null, null, null, 
null);
-
-        assertThat(result.getItems()).isEmpty();
-        assertThat(result.getTotal()).isEqualTo(1);
-    }
-
-    @Test
-    void queryLogsShouldPassSearchFilterToRepository() {
-        when(auditRepository.findAll(eq("topic-a"), isNull(), isNull(), 
isNull(), isNull()))
-                .thenReturn(Collections.emptyList());
-
-        auditService.queryLogs(1, 10, "topic-a", null, null, null, null);
-
-        verify(auditRepository).findAll(eq("topic-a"), isNull(), isNull(), 
isNull(), isNull());
-    }
-
-    @Test
-    void queryLogsShouldPassOperationTypeFilterToRepository() {
-        when(auditRepository.findAll(isNull(), eq("CREATE"), isNull(), 
isNull(), isNull()))
-                .thenReturn(Collections.emptyList());
+                2, 20, "topic-a", "CREATE", null, null, "SUCCESS");
 
-        auditService.queryLogs(1, 10, null, "CREATE", null, null, null);
-
-        verify(auditRepository).findAll(isNull(), eq("CREATE"), isNull(), 
isNull(), isNull());
-    }
-
-    @Test
-    void queryLogsShouldPassResultFilterToRepository() {
-        when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), 
eq("SUCCESS")))
-                .thenReturn(Collections.emptyList());
-
-        auditService.queryLogs(1, 10, null, null, null, null, "SUCCESS");
-
-        verify(auditRepository).findAll(isNull(), isNull(), isNull(), 
isNull(), eq("SUCCESS"));
+        assertThat(result.getItems()).containsExactly(record);
+        assertThat(result.getTotal()).isEqualTo(21);
+        verify(auditRepository).findPage(eq("topic-a"), eq("CREATE"), 
isNull(), isNull(), eq("SUCCESS"),
+                eq(2), eq(20));
     }
 
     @Test
-    void queryLogsShouldParseDateRange() {
-        when(auditRepository.findAll(isNull(), isNull(), 
any(LocalDateTime.class), any(LocalDateTime.class), isNull()))
-                .thenReturn(Collections.emptyList());
-
-        auditService.queryLogs(1, 10, null, null, "2025-01-01", "2025-01-31", 
null);
+    void queryLogsParsesDateRangeBeforeDelegating() {
+        when(auditRepository.findPage(isNull(), isNull(), 
any(LocalDateTime.class), any(LocalDateTime.class),
+                isNull(), eq(1), eq(10))).thenReturn(PageResult.empty(1, 10));
 
-        ArgumentCaptor<LocalDateTime> startCaptor = 
ArgumentCaptor.forClass(LocalDateTime.class);
-        ArgumentCaptor<LocalDateTime> endCaptor = 
ArgumentCaptor.forClass(LocalDateTime.class);
-        verify(auditRepository).findAll(isNull(), isNull(), 
startCaptor.capture(), endCaptor.capture(), isNull());
+        auditService.queryLogs(1, 10, null, null, "2026-08-01", "2026-08-02", 
null);
 
-        assertThat(startCaptor.getValue()).isEqualTo(LocalDateTime.of(2025, 1, 
1, 0, 0, 0));
-        assertThat(endCaptor.getValue().getYear()).isEqualTo(2025);
-        assertThat(endCaptor.getValue().getMonthValue()).isEqualTo(1);
-        assertThat(endCaptor.getValue().getDayOfMonth()).isEqualTo(31);
-        assertThat(endCaptor.getValue().getHour()).isEqualTo(23);
-        assertThat(endCaptor.getValue().getMinute()).isEqualTo(59);
+        ArgumentCaptor<LocalDateTime> start = 
ArgumentCaptor.forClass(LocalDateTime.class);
+        ArgumentCaptor<LocalDateTime> end = 
ArgumentCaptor.forClass(LocalDateTime.class);
+        verify(auditRepository).findPage(isNull(), isNull(), start.capture(), 
end.capture(), isNull(), eq(1), eq(10));
+        assertThat(start.getValue()).isEqualTo(LocalDateTime.of(2026, 8, 1, 0, 
0));
+        assertThat(end.getValue()).isEqualTo(LocalDateTime.of(2026, 8, 2, 23, 
59, 59, 999_999_999));
     }
 
     @Test
-    void queryLogsShouldRejectInvalidStartDate() {
-        assertThatThrownBy(() -> auditService.queryLogs(
-                1, 10, null, null, "invalid-date", null, null))
+    void queryLogsRejectsInvalidPageBounds() {
+        assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null, 
null, null, null))
                 .isInstanceOf(BusinessException.class)
-                .hasMessage("startDate must use YYYY-MM-DD")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
-    }
-
-    @Test
-    void queryLogsShouldRejectInvalidEndDate() {
-        assertThatThrownBy(() -> auditService.queryLogs(
-                1, 10, null, null, null, "2025-02-30", null))
+                .hasMessage("page must be greater than 0");
+        assertThatThrownBy(() -> auditService.queryLogs(1, 101, null, null, 
null, null, null))
                 .isInstanceOf(BusinessException.class)
-                .hasMessage("endDate must use YYYY-MM-DD")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+                .hasMessage("pageSize must be between 1 and 100");
     }
 
     @Test
-    void queryLogsShouldRejectReversedDateRange() {
-        assertThatThrownBy(() -> auditService.queryLogs(
-                1, 10, null, null, "2025-02-01", "2025-01-31", null))
+    void queryLogsRejectsInvalidDateRange() {
+        assertThatThrownBy(() -> auditService.queryLogs(1, 10, null, null, 
"2026-08-02", "2026-08-01", null))
                 .isInstanceOf(BusinessException.class)
-                .hasMessage("startDate must not be after endDate")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+                .hasMessage("startDate must not be after endDate");
     }
 
     @Test
-    void cleanupLogsShouldDeleteOldRecords() {
-        
when(auditRepository.deleteBefore(any(LocalDateTime.class))).thenReturn(42);
-
-        int result = auditService.cleanupLogs(30);
+    void exportLogsIncludesPersistedAuditContextAndEscapesCsvCells() {
+        AuditRecordVO record = AuditRecordVO.builder()
+                .timestamp(LocalDateTime.of(2026, 8, 1, 9, 30))
+                .operator("=cmd")
+                .operationType("DELETE")
+                .resourceType("TOPIC")
+                .target("topic,a")
+                .clusterId("prod-cn")
+                .detail("removed \"topic\"")
+                .result("FAILED")
+                .errorMessage("=denied")
+                .build();
+        when(auditRepository.findPage(eq("topic"), eq("DELETE"), 
any(LocalDateTime.class),
+                any(LocalDateTime.class), eq("FAILED"), eq(1), eq(10_000)))
+                .thenReturn(PageResult.of(List.of(record), 1, 1, 10_000));
 
-        assertThat(result).isEqualTo(42);
-        ArgumentCaptor<LocalDateTime> captor = 
ArgumentCaptor.forClass(LocalDateTime.class);
-        verify(auditRepository).deleteBefore(captor.capture());
+        String csv = auditService.exportLogs("topic", "DELETE", "2026-08-01", 
"2026-08-02", "FAILED");
 
-        LocalDateTime cutoff = captor.getValue();
-        LocalDateTime expected = LocalDateTime.now().minusDays(30);
-        assertThat(cutoff).isCloseTo(expected, 
org.assertj.core.api.Assertions.within(2, 
java.time.temporal.ChronoUnit.SECONDS));
+        
assertThat(csv).contains("resourceType,target,clusterId,detail,result,errorMessage")
+                
.contains("\"'=cmd\",\"DELETE\",\"TOPIC\",\"topic,a\",\"prod-cn\"")
+                .contains("\"'=denied\"");
     }
 
     @Test
-    void cleanupLogsShouldReturnZeroWhenNoOldRecords() {
-        
when(auditRepository.deleteBefore(any(LocalDateTime.class))).thenReturn(0);
-
-        int result = auditService.cleanupLogs(90);
+    void exportLogsRejectsResultsBeyondBound() {
+        when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(), 
isNull(), eq(1), eq(10_000)))
+                .thenReturn(PageResult.of(List.of(), 10_001, 1, 10_000));
 
-        assertThat(result).isZero();
+        assertThatThrownBy(() -> auditService.exportLogs(null, null, null, 
null, null))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Audit log export exceeds the maximum of 10000 
records; narrow the filters");
     }
 
     @Test
-    void cleanupLogsShouldRejectNonPositiveRetention() {
+    void cleanupLogsRejectsNonPositiveRetention() {
         assertThatThrownBy(() -> auditService.cleanupLogs(0))
-                .isInstanceOf(BusinessException.class)
-                .hasMessage("beforeDays must be greater than 0")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
-
-        assertThatThrownBy(() -> auditService.cleanupLogs(-1))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("beforeDays must be greater than 0");
     }
-
-    @Test
-    void queryLogsShouldHandleAllFiltersTogether() {
-        when(auditRepository.findAll(eq("admin"), eq("DELETE"), 
any(LocalDateTime.class),
-                any(LocalDateTime.class), eq("FAILURE")))
-                .thenReturn(Collections.emptyList());
-
-        auditService.queryLogs(1, 10, "admin", "DELETE", "2025-06-01", 
"2025-06-30", "FAILURE");
-
-        verify(auditRepository).findAll(eq("admin"), eq("DELETE"), 
any(LocalDateTime.class),
-                any(LocalDateTime.class), eq("FAILURE"));
-    }
-
-    @Test
-    void exportLogsShouldUseFiltersAndEscapeCsvValues() {
-        AuditRecordVO record = AuditRecordVO.builder()
-                .timestamp(LocalDateTime.of(2026, 8, 1, 9, 30))
-                .operator("=cmd")
-                .operationType("DELETE")
-                .target("topic,a")
-                .detail("removed \"topic\"\nfrom cluster")
-                .ipAddress("\n=127.0.0.1")
-                .result("SUCCESS")
-                .build();
-        when(auditRepository.findAll(eq("topic"), eq("DELETE"), 
any(LocalDateTime.class),
-                any(LocalDateTime.class), eq("SUCCESS")))
-                .thenReturn(List.of(record));
-
-        String csv = auditService.exportLogs(
-                "topic", "DELETE", "2026-08-01", "2026-08-02", "SUCCESS");
-
-        
assertThat(csv).isEqualTo("\uFEFFtimestamp,operator,operationType,target,detail,ipAddress,result\r\n"
-                + "\"2026-08-01T09:30\",\"'=cmd\",\"DELETE\",\"topic,a\","
-                + "\"removed \"\"topic\"\"\nfrom 
cluster\",\"'\n=127.0.0.1\",\"SUCCESS\"\r\n");
-        verify(auditRepository).findAll(eq("topic"), eq("DELETE"), 
any(LocalDateTime.class),
-                any(LocalDateTime.class), eq("SUCCESS"));
-    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepositoryTest.java
new file mode 100644
index 00000000..46fe7f09
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepositoryTest.java
@@ -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
+ * (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.ops.audit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.persistence.entity.RmqOperationAudit;
+import org.apache.rocketmq.studio.persistence.mapper.RmqOperationAuditMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MybatisPlusAuditRepositoryTest {
+
+    @Mock
+    private RmqOperationAuditMapper auditMapper;
+
+    @InjectMocks
+    private MybatisPlusAuditRepository repository;
+
+    @Test
+    void findPageUsesMapperPaginationAndPreservesAuditContext() {
+        RmqOperationAudit entity = new RmqOperationAudit();
+        entity.setId(42L);
+        entity.setOperation("DELETE_TOPIC");
+        entity.setResourceType("TOPIC");
+        entity.setResourceName("orders");
+        entity.setClusterId("prod-cn");
+        entity.setDetail("delete requested");
+        entity.setResult("FAILED");
+        entity.setErrorMessage("denied");
+        entity.setOperatedAt(LocalDateTime.of(2026, 8, 4, 10, 15));
+        Page<RmqOperationAudit> mapperPage = new Page<RmqOperationAudit>(2, 25)
+                .setRecords(List.of(entity))
+                .setTotal(126);
+        when(auditMapper.selectPage(any(IPage.class), 
any(Wrapper.class))).thenReturn(mapperPage);
+
+        PageResult<AuditRecordVO> result = repository.findPage(
+                "orders", "DELETE_TOPIC", null, null, "FAILED", 2, 25);
+
+        ArgumentCaptor<IPage<RmqOperationAudit>> pageCaptor = 
ArgumentCaptor.forClass(IPage.class);
+        verify(auditMapper).selectPage(pageCaptor.capture(), 
any(Wrapper.class));
+        assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2);
+        assertThat(pageCaptor.getValue().getSize()).isEqualTo(25);
+        assertThat(result.getTotal()).isEqualTo(126);
+        AuditRecordVO record = result.getItems().get(0);
+        assertThat(record.getId()).isEqualTo("42");
+        assertThat(record.getResourceType()).isEqualTo("TOPIC");
+        assertThat(record.getClusterId()).isEqualTo("prod-cn");
+        assertThat(record.getErrorMessage()).isEqualTo("denied");
+    }
+}
diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts
index b9e39d38..1491c8a6 100644
--- a/web/src/api/ops.test.ts
+++ b/web/src/api/ops.test.ts
@@ -235,10 +235,12 @@ describe('Ops API - System Alerts & Audit', () => {
             timestamp: '2026-01-01',
             operator: 'admin',
             operationType: 'CREATE',
+            resourceType: 'TOPIC',
             target: 'topic',
+            clusterId: 'prod-cn',
             detail: 'Created topic',
-            ipAddress: '127.0.0.1',
             result: 'SUCCESS',
+            errorMessage: '',
           },
         ],
         total: 1,
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 356d5021..e35ce9a0 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -31,10 +31,12 @@ export interface AuditRecord {
   timestamp: string;
   operator: string;
   operationType: string;
+  resourceType: string;
   target: string;
+  clusterId: string;
   detail: string;
-  ipAddress: string;
   result: string;
+  errorMessage: string;
 }
 
 export interface PageResult<T> {
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index fd7f40c8..a6081e62 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -262,10 +262,12 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'audit.time': { zh: '操作时间', en: 'Time' },
   'audit.operator': { zh: '操作人', en: 'Operator' },
   'audit.opType': { zh: '操作类型', en: 'Operation Type' },
+  'audit.resourceType': { zh: '资源类型', en: 'Resource Type' },
   'audit.target': { zh: '操作对象', en: 'Target' },
   'audit.detail': { zh: '操作详情', en: 'Detail' },
-  'audit.ip': { zh: 'IP 地址', en: 'IP Address' },
+  'audit.cluster': { zh: '集群', en: 'Cluster' },
   'audit.result': { zh: '结果', en: 'Result' },
+  'audit.error': { zh: '失败原因', en: 'Error' },
   'audit.searchPlaceholder': { zh: '搜索操作人或操作对象', en: 'Search operator or 
target' },
   'audit.cleanup': { zh: '清理日志', en: 'Cleanup' },
   'audit.cleanupTitle': { zh: '清理审计日志', en: 'Cleanup Audit Log' },
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx 
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index 89eeb760..663f0a35 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -66,10 +66,12 @@ describe('Audit page', () => {
           timestamp: '2026-08-01 10:00:00',
           operator: 'admin',
           operationType: '删除Topic',
+          resourceType: 'TOPIC',
           target: 'topic-a',
+          clusterId: 'prod-cn',
           detail: 'removed topic-a',
-          ipAddress: '127.0.0.1',
           result: 'SUCCESS',
+          errorMessage: '',
         },
       ],
       total: 1,
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index a28bf8e4..e18bd638 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -162,6 +162,18 @@ const AuditPage: React.FC = () => {
       width: 120,
       render: (type: string) => <Tag color={operationTypeColors[type] || 
'default'}>{type}</Tag>,
     },
+    {
+      title: t('audit.resourceType'),
+      dataIndex: 'resourceType',
+      width: 120,
+      ellipsis: true,
+    },
+    {
+      title: t('audit.cluster'),
+      dataIndex: 'clusterId',
+      width: 140,
+      ellipsis: true,
+    },
     {
       title: t('audit.target'),
       dataIndex: 'target',
@@ -173,11 +185,6 @@ const AuditPage: React.FC = () => {
       dataIndex: 'detail',
       ellipsis: true,
     },
-    {
-      title: t('audit.ip'),
-      dataIndex: 'ipAddress',
-      width: 140,
-    },
     {
       title: t('audit.result'),
       dataIndex: 'result',
@@ -189,6 +196,11 @@ const AuditPage: React.FC = () => {
           <Tag color="red">{t('common.failure')}</Tag>
         ),
     },
+    {
+      title: t('audit.error'),
+      dataIndex: 'errorMessage',
+      ellipsis: true,
+    },
   ];
 
   return (
diff --git a/web/src/services/opsService.test.ts 
b/web/src/services/opsService.test.ts
index e06f1448..8ed1d707 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -125,10 +125,12 @@ describe('ops service mock data', () => {
       timestamp: '2026-07-26 10:00:00',
       operator: null,
       operationType: 'DIAGNOSE',
+      resourceType: 'CLIENT',
       target: null,
+      clusterId: null,
       detail: 'Describe gRPC client connection',
-      ipAddress: '127.0.0.1',
       result: 'success',
+      errorMessage: null,
     } as unknown as AuditRecord;
     insertedRecords.push(record);
     auditRecords.push(record);
@@ -144,20 +146,24 @@ describe('ops service mock data', () => {
       timestamp: '2026-08-01 10:00:00',
       operator: '=admin',
       operationType: 'DELETE',
+      resourceType: 'TOPIC',
       target: 'csv-export-target',
+      clusterId: 'prod-cn',
       detail: 'removed "topic", safely',
-      ipAddress: '\n=127.0.0.1',
       result: 'SUCCESS',
+      errorMessage: '=denied',
     } as AuditRecord;
     insertedRecords.push(record);
     auditRecords.push(record);
 
     const csv = await exportAuditLogs({ search: 'csv-export-target' });
 
-    
expect(csv).toContain('timestamp,operator,operationType,target,detail,ipAddress,result');
     expect(csv).toContain(
-      '"2026-08-01 10:00:00","\'=admin","DELETE","csv-export-target",' +
-        '"removed ""topic"", safely","\'\n=127.0.0.1","SUCCESS"',
+      
'timestamp,operator,operationType,resourceType,target,clusterId,detail,result,errorMessage',
+    );
+    expect(csv).toContain(
+      '"2026-08-01 10:00:00","\'=admin","DELETE","TOPIC","csv-export-target",' 
+
+        '"prod-cn","removed ""topic"", safely","SUCCESS","\'=denied"',
     );
   });
 });
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 0bcde5f0..5cd36c08 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -54,16 +54,19 @@ function toCsvCell(value: string | null | undefined): 
string {
 }
 
 function formatAuditCsv(records: AuditRecord[]): string {
-  const header = 
'timestamp,operator,operationType,target,detail,ipAddress,result\r\n';
+  const header =
+    
'timestamp,operator,operationType,resourceType,target,clusterId,detail,result,errorMessage\r\n';
   const rows = records.map((record) =>
     [
       record.timestamp,
       record.operator,
       record.operationType,
+      record.resourceType,
       record.target,
+      record.clusterId,
       record.detail,
-      record.ipAddress,
       record.result,
+      record.errorMessage,
     ]
       .map(toCsvCell)
       .join(','),

Reply via email to