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(','),