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 38c786b2b feat(audit): add filtered activity summary dashboard (#1923)
38c786b2b is described below
commit 38c786b2b6ce857599876e9c3fcdea18e0ca809e
Author: btlqql <[email protected]>
AuthorDate: Wed Sep 2 17:25:37 2026 +0800
feat(audit): add filtered activity summary dashboard (#1923)
* feat(audit): add filtered activity summary dashboard
* fix(audit): address review on summary dashboard aggregates
Address the review feedback on the audit summary dashboard:
- Replace the four per-outcome COUNT(*) statements with a single
GROUP BY result query; total/SUCCESS/FAILED/PARTIAL derive from it.
- Compute unique operators with COUNT(DISTINCT operator) in SQL so rows
are no longer pulled into memory just to be counted.
- Add repository tests covering result aggregation, hotspot ordering,
the top-N dashboard limit, case-insensitive aggregate label mapping,
and filter propagation across every aggregate query.
- Align the backend hotspot limit with the dashboard top-N list.
- Reset the summary loading flag on filter change so a stale summary is
not shown while the refreshed aggregate loads.
---
.../rocketmq/studio/ops/audit/AuditController.java | 13 ++
.../rocketmq/studio/ops/audit/AuditRepository.java | 4 +
.../rocketmq/studio/ops/audit/AuditService.java | 19 ++-
...itRepository.java => AuditSummaryBucketVO.java} | 24 ++--
.../{AuditRepository.java => AuditSummaryVO.java} | 31 +++--
.../ops/audit/MybatisPlusAuditRepository.java | 133 ++++++++++++++++++++
.../studio/ops/audit/AuditControllerTest.java | 30 +++++
.../studio/ops/audit/AuditServiceTest.java | 18 +++
.../ops/audit/MybatisPlusAuditRepositoryTest.java | 137 +++++++++++++++++++++
web/src/api/audit.test.ts | 23 +++-
web/src/api/audit.ts | 21 ++++
web/src/pages/ops/AuditSummaryCards.tsx | 111 +++++++++++++++++
web/src/pages/ops/__tests__/AuditPage.test.tsx | 29 +++++
web/src/pages/ops/audit.tsx | 31 ++++-
web/src/services/opsService.ts | 37 +++++-
15 files changed, 630 insertions(+), 31 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
index ddac5f9f4..0213808f1 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
@@ -56,6 +56,19 @@ public class AuditController {
return Result.ok(auditService.getFilterOptions());
}
+ @GetMapping("/summary")
+ public Result<AuditSummaryVO> summarize(
+ @RequestParam(required = false) String search,
+ @RequestParam(required = false) String operationType,
+ @RequestParam(required = false) String resourceType,
+ @RequestParam(required = false) String clusterId,
+ @RequestParam(required = false) String startDate,
+ @RequestParam(required = false) String endDate,
+ @RequestParam(required = false) String result) {
+ return Result.ok(auditService.summarize(search, operationType,
resourceType,
+ clusterId, startDate, endDate, result));
+ }
+
@GetMapping("/export")
public Result<String> exportLogs(
@RequestParam(required = false) String search,
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 90ec12a38..e3f2754a2 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
@@ -27,6 +27,10 @@ public interface AuditRepository {
AuditFilterOptionsVO findFilterOptions();
+ AuditSummaryVO summarize(String search, String operationType, String
resourceType,
+ String clusterId, LocalDateTime startDate,
LocalDateTime endDate,
+ String result);
+
void save(AuditRecordVO record);
int deleteBefore(LocalDateTime cutoff, int batchSize, int maxBatches);
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 a7ca5e243..881a0e09e 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
@@ -61,6 +61,13 @@ public class AuditService {
return auditRepository.findFilterOptions();
}
+ public AuditSummaryVO summarize(String search, String operationType,
String resourceType,
+ String clusterId, String startDate, String
endDate, String result) {
+ DateRange range = parseDateRange(startDate, endDate);
+ return auditRepository.summarize(search, operationType, resourceType,
clusterId,
+ range.start(), range.end(), result);
+ }
+
public String exportLogs(String search, String operationType, String
resourceType,
String clusterId, String startDate, String
endDate, String result) {
PageResult<AuditRecordVO> page = findPage(
@@ -136,13 +143,18 @@ public class AuditService {
String resourceType, String
clusterId,
String startDate, String
endDate,
String result, int page, int
pageSize) {
+ DateRange range = parseDateRange(startDate, endDate);
+ return auditRepository.findPage(search, operationType, resourceType,
clusterId,
+ range.start(), range.end(), result, page, pageSize);
+ }
+
+ private DateRange parseDateRange(String startDate, String endDate) {
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.findPage(search, operationType, resourceType,
clusterId,
- start, end, result, page, pageSize);
+ return new DateRange(start, end);
}
private LocalDateTime parseDate(String dateStr, boolean startOfDay, String
parameterName) {
@@ -156,4 +168,7 @@ public class AuditService {
throw new BusinessException(400, parameterName + " must use
YYYY-MM-DD");
}
}
+
+ private record DateRange(LocalDateTime start, LocalDateTime end) {
+ }
}
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/AuditSummaryBucketVO.java
similarity index 58%
copy from
server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRepository.java
copy to
server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditSummaryBucketVO.java
index 90ec12a38..cbc0eddcd 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/AuditSummaryBucketVO.java
@@ -16,18 +16,16 @@
*/
package org.apache.rocketmq.studio.ops.audit;
-import java.time.LocalDateTime;
-import org.apache.rocketmq.studio.common.domain.PageResult;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
-public interface AuditRepository {
- PageResult<AuditRecordVO> findPage(String search, String operationType,
- String resourceType, String clusterId,
- LocalDateTime startDate, LocalDateTime
endDate,
- String result, int page, int pageSize);
-
- AuditFilterOptionsVO findFilterOptions();
-
- void save(AuditRecordVO record);
-
- int deleteBefore(LocalDateTime cutoff, int batchSize, int maxBatches);
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuditSummaryBucketVO {
+ private String name;
+ private long count;
}
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/AuditSummaryVO.java
similarity index 60%
copy from
server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditRepository.java
copy to
server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditSummaryVO.java
index 90ec12a38..282a943c4 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/AuditSummaryVO.java
@@ -16,18 +16,25 @@
*/
package org.apache.rocketmq.studio.ops.audit;
-import java.time.LocalDateTime;
-import org.apache.rocketmq.studio.common.domain.PageResult;
-
-public interface AuditRepository {
- PageResult<AuditRecordVO> findPage(String search, String operationType,
- String resourceType, String clusterId,
- LocalDateTime startDate, LocalDateTime
endDate,
- String result, int page, int pageSize);
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
- AuditFilterOptionsVO findFilterOptions();
-
- void save(AuditRecordVO record);
+import java.time.LocalDateTime;
+import java.util.List;
- int deleteBefore(LocalDateTime cutoff, int batchSize, int maxBatches);
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuditSummaryVO {
+ private long total;
+ private long successful;
+ private long failed;
+ private long partial;
+ private long uniqueOperators;
+ private LocalDateTime latestAt;
+ private List<AuditSummaryBucketVO> byOperation;
+ private List<AuditSummaryBucketVO> byResourceType;
}
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 9e62d1036..06128053e 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
@@ -26,10 +26,12 @@ import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
import java.util.stream.Collectors;
/** MySQL-backed audit repository (rmq_operation_audit). */
@@ -39,6 +41,13 @@ public class MybatisPlusAuditRepository implements
AuditRepository {
private static final long FILTER_OPTIONS_CACHE_TTL_NANOS =
TimeUnit.SECONDS.toNanos(30);
+ /**
+ * Maximum number of hot-spot buckets returned for the byOperation /
+ * byResourceType breakdowns. Kept in sync with the dashboard, which
+ * renders the top N entries of each breakdown.
+ */
+ static final int HOTSPOT_LIMIT = 5;
+
private final RmqOperationAuditMapper auditMapper;
private volatile CachedFilterOptions cachedFilterOptions;
@@ -98,6 +107,130 @@ public class MybatisPlusAuditRepository implements
AuditRepository {
.build();
}
+ @Override
+ public AuditSummaryVO summarize(String search, String operationType,
String resourceType,
+ String clusterId, LocalDateTime startDate,
LocalDateTime endDate,
+ String result) {
+ Consumer<QueryWrapper<RmqOperationAudit>> filters = query ->
applyFilters(query, search,
+ operationType, resourceType, clusterId, startDate, endDate,
result);
+
+ // One GROUP BY result query computes total / SUCCESS / FAILED /
PARTIAL in a single
+ // round trip instead of four separate COUNT(*) statements. Note that
when the caller
+ // already filters on a specific result, the buckets for the other
outcomes are
+ // intentionally reported as zero, because those rows are filtered out.
+ Map<String, Long> resultCounts = resultCounts(filters);
+ long total =
resultCounts.values().stream().mapToLong(Long::longValue).sum();
+
+ // COUNT(DISTINCT operator) is evaluated inside the database so
matching rows are
+ // never materialized into the application just to count operators.
+ long uniqueOperators = countDistinctOperators(filters);
+
+ LocalDateTime latestAt = latestOperatedAt(filters);
+
+ return AuditSummaryVO.builder()
+ .total(total)
+ .successful(resultCounts.getOrDefault("SUCCESS", 0L))
+ .failed(resultCounts.getOrDefault("FAILED", 0L))
+ .partial(resultCounts.getOrDefault("PARTIAL", 0L))
+ .uniqueOperators(uniqueOperators)
+ .latestAt(latestAt)
+ .byOperation(groupCounts("operation", filters))
+ .byResourceType(groupCounts("resource_type", filters))
+ .build();
+ }
+
+ private Map<String, Long>
resultCounts(Consumer<QueryWrapper<RmqOperationAudit>> filters) {
+ QueryWrapper<RmqOperationAudit> query = new
QueryWrapper<RmqOperationAudit>()
+ .select("result", "COUNT(*) AS result_count")
+ .groupBy("result");
+ filters.accept(query);
+ Map<String, Long> counts = new LinkedHashMap<>();
+ for (Map<String, Object> row : auditMapper.selectMaps(query)) {
+ String key = mapValue(row, "result");
+ counts.merge(key, parseCount(row, "result_count"), Long::sum);
+ }
+ return counts;
+ }
+
+ private long
countDistinctOperators(Consumer<QueryWrapper<RmqOperationAudit>> filters) {
+ QueryWrapper<RmqOperationAudit> query = new
QueryWrapper<RmqOperationAudit>()
+ .select("COUNT(DISTINCT operator) AS operator_count")
+ .isNotNull("operator");
+ filters.accept(query);
+ List<Map<String, Object>> rows = auditMapper.selectMaps(query);
+ if (rows.isEmpty()) {
+ return 0L;
+ }
+ String value = mapValue(rows.get(0), "operator_count");
+ return value.isEmpty() ? 0L : Long.parseLong(value);
+ }
+
+ private LocalDateTime
latestOperatedAt(Consumer<QueryWrapper<RmqOperationAudit>> filters) {
+ QueryWrapper<RmqOperationAudit> query = new
QueryWrapper<RmqOperationAudit>()
+ .select("gmt_create").orderByDesc("gmt_create").last("LIMIT
1");
+ filters.accept(query);
+ List<RmqOperationAudit> rows = auditMapper.selectList(query);
+ return rows.isEmpty() ? null : rows.get(0).getGmtCreate();
+ }
+
+ private List<AuditSummaryBucketVO> groupCounts(
+ String column, Consumer<QueryWrapper<RmqOperationAudit>> filters) {
+ QueryWrapper<RmqOperationAudit> query = new
QueryWrapper<RmqOperationAudit>()
+ .select(column + " AS bucket_name", "COUNT(*) AS bucket_count")
+ .isNotNull(column)
+ .groupBy(column);
+ filters.accept(query);
+ return auditMapper.selectMaps(query).stream()
+ .map(row -> AuditSummaryBucketVO.builder()
+ .name(mapValue(row, "bucket_name"))
+ .count(parseCount(row, "bucket_count"))
+ .build())
+ .filter(bucket -> StringUtils.hasText(bucket.getName()))
+ .sorted((left, right) -> {
+ int countOrder = Long.compare(right.getCount(),
left.getCount());
+ return countOrder != 0 ? countOrder :
left.getName().compareTo(right.getName());
+ })
+ .limit(HOTSPOT_LIMIT)
+ .toList();
+ }
+
+ /**
+ * Reads an aggregate column value from a result row using
case-insensitive key
+ * matching, because JDBC drivers are free to return label casing
differently.
+ */
+ private long parseCount(Map<String, Object> row, String key) {
+ String value = mapValue(row, key);
+ if (value.isEmpty()) {
+ return 0L;
+ }
+ return Long.parseLong(value);
+ }
+
+ private String mapValue(Map<String, Object> row, String key) {
+ return row.entrySet().stream()
+ .filter(entry -> key.equalsIgnoreCase(entry.getKey()))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .map(Object::toString)
+ .findFirst()
+ .orElse("");
+ }
+
+ private void applyFilters(QueryWrapper<RmqOperationAudit> query, String
search,
+ String operationType, String resourceType,
String clusterId,
+ LocalDateTime startDate, LocalDateTime endDate,
String result) {
+ query.and(StringUtils.hasText(search), w -> w
+ .like("operator", search)
+ .or().like("resource_name", search)
+ .or().like("detail", search))
+ .eq(StringUtils.hasText(operationType), "operation",
operationType)
+ .eq(StringUtils.hasText(resourceType), "resource_type",
resourceType)
+ .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId)
+ .ge(startDate != null, "gmt_create", startDate)
+ .le(endDate != null, "gmt_create", endDate)
+ .eq(StringUtils.hasText(result), "result", result);
+ }
+
@Override
public void save(AuditRecordVO record) {
RmqOperationAudit entity = new RmqOperationAudit();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
index 673d2683a..29b32388b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
@@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
+import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -215,4 +216,33 @@ class AuditControllerTest {
verify(auditService).getFilterOptions();
}
+
+ @Test
+ void summaryShouldForwardFiltersAndReturnDashboardMetricsTest() throws
Exception {
+ AuditSummaryVO summary = AuditSummaryVO.builder()
+
.total(12).successful(9).failed(2).partial(1).uniqueOperators(3)
+ .latestAt(LocalDateTime.of(2026, 8, 12, 9, 30))
+ .byOperation(List.of(new AuditSummaryBucketVO("DELETE_TOPIC",
5)))
+ .byResourceType(List.of(new AuditSummaryBucketVO("TOPIC", 8)))
+ .build();
+ when(auditService.summarize(eq("topic"), eq("DELETE_TOPIC"),
eq("TOPIC"), eq("prod-cn"),
+ eq("2026-08-01"), eq("2026-08-12"),
eq("SUCCESS"))).thenReturn(summary);
+
+ mockMvc.perform(get("/api/audit-logs/summary")
+ .param("search", "topic")
+ .param("operationType", "DELETE_TOPIC")
+ .param("resourceType", "TOPIC")
+ .param("clusterId", "prod-cn")
+ .param("startDate", "2026-08-01")
+ .param("endDate", "2026-08-12")
+ .param("result", "SUCCESS"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(12))
+ .andExpect(jsonPath("$.data.successful").value(9))
+ .andExpect(jsonPath("$.data.uniqueOperators").value(3))
+
.andExpect(jsonPath("$.data.byOperation[0].name").value("DELETE_TOPIC"));
+
+ verify(auditService).summarize(eq("topic"), eq("DELETE_TOPIC"),
eq("TOPIC"), eq("prod-cn"),
+ eq("2026-08-01"), eq("2026-08-12"), eq("SUCCESS"));
+ }
}
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 43331f47f..5d2ca0e76 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
@@ -188,6 +188,24 @@ class AuditServiceTest {
verify(auditRepository).findFilterOptions();
}
+ @Test
+ void summarizeParsesAndForwardsTheSharedFilterRangeTest() {
+ AuditSummaryVO summary =
AuditSummaryVO.builder().total(12).successful(10).failed(2).build();
+ when(auditRepository.summarize(eq("topic"), eq("DELETE_TOPIC"),
eq("TOPIC"), eq("prod-cn"),
+ any(LocalDateTime.class), any(LocalDateTime.class),
eq("FAILED"))).thenReturn(summary);
+
+ AuditSummaryVO actual = auditService.summarize("topic",
"DELETE_TOPIC", "TOPIC", "prod-cn",
+ "2026-08-01", "2026-08-02", "FAILED");
+
+ assertThat(actual).isSameAs(summary);
+ ArgumentCaptor<LocalDateTime> start =
ArgumentCaptor.forClass(LocalDateTime.class);
+ ArgumentCaptor<LocalDateTime> end =
ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(auditRepository).summarize(eq("topic"), eq("DELETE_TOPIC"),
eq("TOPIC"), eq("prod-cn"),
+ start.capture(), end.capture(), eq("FAILED"));
+ 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 cleanupLogsRejectsNonPositiveRetention() {
assertThatThrownBy(() -> auditService.cleanupLogs(0))
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
index c518bccb2..ebe8f9dd0 100644
---
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
@@ -167,4 +167,141 @@ class MybatisPlusAuditRepositoryTest {
audit.setId(id);
return audit;
}
+
+ @Test
+ void
summarizeAggregatesResultCountsInOneGroupByAndCountsOperatorsInSqlTest() {
+ RmqOperationAudit latest = new RmqOperationAudit();
+ latest.setGmtCreate(LocalDateTime.of(2026, 8, 17, 12, 0));
+
+ when(auditMapper.selectMaps(any(Wrapper.class)))
+ .thenReturn(List.of(
+ row("result", "SUCCESS", "result_count", 5L),
+ row("result", "FAILED", "result_count", 2L),
+ row("result", "PARTIAL", "result_count", 1L)))
+ .thenReturn(List.of(row("operator_count", 3L)))
+ .thenReturn(List.of(
+ row("bucket_name", "DELETE_TOPIC", "bucket_count", 4L),
+ row("bucket_name", "CREATE_TOPIC", "bucket_count",
4L)))
+ .thenReturn(List.of(row("bucket_name", "TOPIC",
"bucket_count", 8L)));
+
when(auditMapper.selectList(any(Wrapper.class))).thenReturn(List.of(latest));
+
+ AuditSummaryVO summary = repository.summarize(
+ null, null, null, null, null, null, null);
+
+ assertThat(summary.getTotal()).isEqualTo(8);
+ assertThat(summary.getSuccessful()).isEqualTo(5);
+ assertThat(summary.getFailed()).isEqualTo(2);
+ assertThat(summary.getPartial()).isEqualTo(1);
+ assertThat(summary.getUniqueOperators()).isEqualTo(3);
+ assertThat(summary.getLatestAt()).isEqualTo(LocalDateTime.of(2026, 8,
17, 12, 0));
+ // Equal-count buckets fall back to name ordering for a stable
dashboard.
+
assertThat(summary.getByOperation()).extracting(AuditSummaryBucketVO::getName)
+ .containsExactly("CREATE_TOPIC", "DELETE_TOPIC");
+
assertThat(summary.getByResourceType()).extracting(AuditSummaryBucketVO::getName)
+ .containsExactly("TOPIC");
+
+ // The result aggregation no longer issues one COUNT(*) per outcome.
+ verify(auditMapper, never()).selectCount(any());
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<Wrapper<RmqOperationAudit>> mapsCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(auditMapper, times(4)).selectMaps(mapsCaptor.capture());
+ List<QueryWrapper<RmqOperationAudit>> queries =
mapsCaptor.getAllValues().stream()
+ .map(wrapper -> (QueryWrapper<RmqOperationAudit>) wrapper)
+ .toList();
+ assertThat(queries.get(0).getSqlSelect()).contains("COUNT(*)");
+ assertThat(queries.get(0).getSqlSegment()).contains("GROUP BY result");
+ assertThat(queries.get(1).getSqlSelect()).contains("COUNT(DISTINCT
operator)");
+ assertThat(queries.get(2).getSqlSegment()).contains("GROUP BY
operation");
+ assertThat(queries.get(3).getSqlSegment()).contains("GROUP BY
resource_type");
+ }
+
+ @Test
+ void
summarizeOrdersHotspotsByCountThenNameAndCapsAtTheDashboardLimitTest() {
+ // Mirrors the top-N list rendered by AuditSummaryCards on the
dashboard.
+ assertThat(MybatisPlusAuditRepository.HOTSPOT_LIMIT).isEqualTo(5);
+
+ when(auditMapper.selectMaps(any(Wrapper.class)))
+ .thenReturn(List.of())
+ .thenReturn(List.of(row("operator_count", 0L)))
+ .thenReturn(List.of(
+ row("bucket_name", "op-a", "bucket_count", 10L),
+ row("bucket_name", "op-b", "bucket_count", 7L),
+ row("bucket_name", "op-c", "bucket_count", 7L),
+ row("bucket_name", "op-d", "bucket_count", 3L),
+ row("bucket_name", "op-e", "bucket_count", 3L),
+ row("bucket_name", "op-f", "bucket_count", 3L),
+ row("bucket_name", "op-g", "bucket_count", 1L)))
+ .thenReturn(List.of());
+ when(auditMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+ AuditSummaryVO summary = repository.summarize(
+ null, null, null, null, null, null, null);
+
+
assertThat(summary.getByOperation()).extracting(AuditSummaryBucketVO::getName)
+ .containsExactly("op-a", "op-b", "op-c", "op-d", "op-e");
+
assertThat(summary.getByOperation()).extracting(AuditSummaryBucketVO::getCount)
+ .containsExactly(10L, 7L, 7L, 3L, 3L);
+ }
+
+ @Test
+ void summarizeReadsAggregateLabelsCaseInsensitivelyTest() {
+ // JDBC drivers may return result-set label casing differently; the
mapping must not care.
+ when(auditMapper.selectMaps(any(Wrapper.class)))
+ .thenReturn(List.of(row("RESULT", "SUCCESS", "RESULT_COUNT",
2L)))
+ .thenReturn(List.of(row("Operator_Count", 1L)))
+ .thenReturn(List.of(row("BUCKET_NAME", "TOPIC",
"BUCKET_COUNT", 2L)))
+ .thenReturn(List.of(row("Bucket_Name", "GROUP",
"bucket_count", 2L)));
+ when(auditMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+ AuditSummaryVO summary = repository.summarize(
+ null, null, null, null, null, null, null);
+
+ assertThat(summary.getSuccessful()).isEqualTo(2);
+ assertThat(summary.getUniqueOperators()).isEqualTo(1);
+ assertThat(summary.getByOperation()).extracting(
+ AuditSummaryBucketVO::getName, AuditSummaryBucketVO::getCount)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("TOPIC",
2L));
+ assertThat(summary.getByResourceType()).extracting(
+ AuditSummaryBucketVO::getName, AuditSummaryBucketVO::getCount)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("GROUP",
2L));
+ }
+
+ @Test
+ void summarizeAppliesEveryFilterToEachAggregateQueryTest() {
+ when(auditMapper.selectMaps(any(Wrapper.class))).thenReturn(List.of());
+ when(auditMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+ LocalDateTime startDate = LocalDateTime.of(2026, 8, 1, 0, 0);
+ LocalDateTime endDate = LocalDateTime.of(2026, 8, 31, 23, 59);
+ repository.summarize("orders", "DELETE_TOPIC", "TOPIC", "prod-cn",
+ startDate, endDate, "SUCCESS");
+
+ List<Wrapper<RmqOperationAudit>> queries = new java.util.ArrayList<>();
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<Wrapper<RmqOperationAudit>> mapsCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(auditMapper, times(4)).selectMaps(mapsCaptor.capture());
+ queries.addAll(mapsCaptor.getAllValues());
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<Wrapper<RmqOperationAudit>> listCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(auditMapper).selectList(listCaptor.capture());
+ queries.add(listCaptor.getValue());
+
+ for (Wrapper<RmqOperationAudit> query : queries) {
+ String segment = query.getSqlSegment();
+ assertThat(segment).contains("operator LIKE", "resource_name
LIKE", "detail LIKE");
+ assertThat(segment).contains("operation =", "resource_type =",
"cluster_id =");
+ assertThat(segment).contains("result =", "gmt_create >=",
"gmt_create <=");
+ }
+ }
+
+ /** Builds a {@code Map<String, Object>} row so mocked result maps keep an
explicit type. */
+ private static Map<String, Object> row(Object... keyValues) {
+ Map<String, Object> result = new java.util.LinkedHashMap<>();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ result.put((String) keyValues[i], keyValues[i + 1]);
+ }
+ return result;
+ }
+
}
diff --git a/web/src/api/audit.test.ts b/web/src/api/audit.test.ts
index 723b7574f..fd80476f7 100644
--- a/web/src/api/audit.test.ts
+++ b/web/src/api/audit.test.ts
@@ -17,7 +17,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
-import { exportAuditLogs, fetchAuditFilterOptions } from './audit';
+import { exportAuditLogs, fetchAuditFilterOptions, fetchAuditSummary } from
'./audit';
import client from './client';
import { cleanupAuditLogs, listAuditRecords } from './ops';
@@ -72,4 +72,25 @@ describe('audit log API', () => {
await expect(exportAuditLogs({ search: 'topic', result: 'SUCCESS'
})).resolves.toBe(csv);
});
+
+ it('loads summary metrics with the supplied filters', async () => {
+ const summary = {
+ total: 10,
+ successful: 8,
+ failed: 1,
+ partial: 1,
+ uniqueOperators: 3,
+ latestAt: '2026-08-12T10:00:00',
+ byOperation: [{ name: 'CREATE_TOPIC', count: 6 }],
+ byResourceType: [{ name: 'TOPIC', count: 9 }],
+ };
+ mock.onGet('/audit-logs/summary').reply((config) => {
+ expect(config.params).toEqual({ clusterId: 'prod-cn', startDate:
'2026-08-01' });
+ return [200, { code: 200, data: summary }];
+ });
+
+ await expect(
+ fetchAuditSummary({ clusterId: 'prod-cn', startDate: '2026-08-01' }),
+ ).resolves.toEqual(summary);
+ });
});
diff --git a/web/src/api/audit.ts b/web/src/api/audit.ts
index f19a1d1ae..33945d3ca 100644
--- a/web/src/api/audit.ts
+++ b/web/src/api/audit.ts
@@ -27,6 +27,22 @@ export interface AuditFilterOptions {
results: string[];
}
+export interface AuditSummaryBucket {
+ name: string;
+ count: number;
+}
+
+export interface AuditSummary {
+ total: number;
+ successful: number;
+ failed: number;
+ partial: number;
+ uniqueOperators: number;
+ latestAt: string | null;
+ byOperation: AuditSummaryBucket[];
+ byResourceType: AuditSummaryBucket[];
+}
+
export async function fetchAuditFilterOptions(): Promise<AuditFilterOptions> {
const res = await client.get<{ data: AuditFilterOptions
}>('/audit-logs/filter-options');
return res.data.data;
@@ -36,3 +52,8 @@ export async function exportAuditLogs(params?: AuditFilter):
Promise<string> {
const res = await client.get<{ data: string }>('/audit-logs/export', {
params });
return res.data.data;
}
+
+export async function fetchAuditSummary(params?: AuditFilter):
Promise<AuditSummary> {
+ const res = await client.get<{ data: AuditSummary }>('/audit-logs/summary',
{ params });
+ return res.data.data;
+}
diff --git a/web/src/pages/ops/AuditSummaryCards.tsx
b/web/src/pages/ops/AuditSummaryCards.tsx
new file mode 100644
index 000000000..5c67c50cc
--- /dev/null
+++ b/web/src/pages/ops/AuditSummaryCards.tsx
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Card, Col, Empty, Flex, Progress, Row, Skeleton, Statistic, Tag,
Typography } from 'antd';
+import type { AuditSummary, AuditSummaryBucket } from '../../api/audit';
+
+const { Text } = Typography;
+
+interface Props {
+ summary: AuditSummary | null;
+ loading: boolean;
+}
+
+const BucketList = ({ items, total }: { items: AuditSummaryBucket[]; total:
number }) => {
+ if (!items.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无数据" />;
+ return (
+ <Flex vertical gap={10}>
+ {items.slice(0, 5).map((item) => (
+ <div key={item.name}>
+ <Flex justify="space-between" gap={8}>
+ <Text ellipsis title={item.name}>
+ {item.name.replace(/_/g, ' ')}
+ </Text>
+ <Tag>{item.count}</Tag>
+ </Flex>
+ <Progress
+ percent={total ? Math.round((item.count / total) * 100) : 0}
+ showInfo={false}
+ size="small"
+ />
+ </div>
+ ))}
+ </Flex>
+ );
+};
+
+const AuditSummaryCards = ({ summary, loading }: Props) => {
+ // Show the placeholder while any fetch is in flight so a filter change does
+ // not keep painting a stale summary until the refreshed aggregate arrives.
+ if (loading) return <Skeleton active paragraph={{ rows: 4 }} />;
+ const data = summary || {
+ total: 0,
+ successful: 0,
+ failed: 0,
+ partial: 0,
+ uniqueOperators: 0,
+ latestAt: null,
+ byOperation: [],
+ byResourceType: [],
+ };
+ const successRate = data.total ? Math.round((data.successful / data.total) *
100) : 0;
+ return (
+ <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+ <Col xs={12} lg={6}>
+ <Card size="small">
+ <Statistic title="匹配记录" value={data.total} />
+ </Card>
+ </Col>
+ <Col xs={12} lg={6}>
+ <Card size="small">
+ <Statistic
+ title="成功率"
+ value={successRate}
+ suffix="%"
+ valueStyle={{ color: '#52c41a' }}
+ />
+ </Card>
+ </Col>
+ <Col xs={12} lg={6}>
+ <Card size="small">
+ <Statistic
+ title="失败 / 部分成功"
+ value={`${data.failed} / ${data.partial}`}
+ valueStyle={{ color: data.failed ? '#cf1322' : undefined }}
+ />
+ </Card>
+ </Col>
+ <Col xs={12} lg={6}>
+ <Card size="small">
+ <Statistic title="操作人数" value={data.uniqueOperators} />
+ </Card>
+ </Col>
+ <Col xs={24} lg={12}>
+ <Card size="small" title="高频操作">
+ <BucketList items={data.byOperation} total={data.total} />
+ </Card>
+ </Col>
+ <Col xs={24} lg={12}>
+ <Card size="small" title="资源类型分布">
+ <BucketList items={data.byResourceType} total={data.total} />
+ </Card>
+ </Col>
+ </Row>
+ );
+};
+
+export default AuditSummaryCards;
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index e50a44dd0..fed3c0322 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -28,6 +28,7 @@ vi.mock('../../../services/opsService', () => ({
cleanupAuditLogs: vi.fn(),
exportAuditLogs: vi.fn(),
getAuditFilterOptions: vi.fn(),
+ getAuditSummary: vi.fn(),
listAuditRecords: vi.fn(),
}));
@@ -93,6 +94,16 @@ describe('Audit page', () => {
page: 1,
size: 20,
});
+ vi.mocked(opsService.getAuditSummary).mockResolvedValue({
+ total: 10,
+ successful: 8,
+ failed: 1,
+ partial: 1,
+ uniqueOperators: 3,
+ latestAt: '2026-08-01 10:00:00',
+ byOperation: [{ name: 'DELETE_TOPIC', count: 6 }],
+ byResourceType: [{ name: 'TOPIC', count: 9 }],
+ });
vi.mocked(opsService.exportAuditLogs).mockResolvedValue(
'\uFEFFtimestamp,operator\r\n"2026-08-01 10:00:00","admin"\r\n',
);
@@ -175,6 +186,24 @@ describe('Audit page', () => {
expect(screen.getByText('timestamp: 1784246400000')).toBeInTheDocument();
});
+ it('loads a filtered server-side summary dashboard', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AuditPage />);
+
+ expect(await screen.findByText('匹配记录')).toBeInTheDocument();
+ expect(screen.getByText('80')).toBeInTheDocument();
+ expect(screen.getAllByText('DELETE TOPIC').length).toBeGreaterThan(0);
+ await user.type(screen.getByPlaceholderText('搜索操作人或操作对象'), 'topic-a');
+
+ await waitFor(
+ () =>
+ expect(opsService.getAuditSummary).toHaveBeenLastCalledWith(
+ expect.objectContaining({ search: 'topic-a' }),
+ ),
+ { timeout: 1000 },
+ );
+ });
+
it('loads persisted filter values and forwards their original codes', async
() => {
const user = userEvent.setup();
renderWithProviders(<AuditPage />);
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index c0d6035fb..97e4b33aa 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -39,12 +39,13 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
-import type { AuditFilter, AuditFilterOptions } from '../../api/audit';
+import type { AuditFilter, AuditFilterOptions, AuditSummary } from
'../../api/audit';
import type { AuditRecord } from '../../api/ops';
import {
cleanupAuditLogs,
exportAuditLogs,
getAuditFilterOptions,
+ getAuditSummary,
listAuditRecords,
} from '../../services/opsService';
import { downloadBlob } from '../../utils/download';
@@ -57,6 +58,7 @@ import {
getAuditResultPresentation,
parseAuditDetail,
} from './auditPresentation';
+import AuditSummaryCards from './AuditSummaryCards';
const emptyFilterOptions: AuditFilterOptions = {
operationTypes: [],
@@ -101,6 +103,8 @@ const AuditPage: React.FC = () => {
const [cleanupModalOpen, setCleanupModalOpen] = useState(false);
const [cleanupDays, setCleanupDays] = useState(30);
const [exporting, setExporting] = useState(false);
+ const [summary, setSummary] = useState<AuditSummary | null>(null);
+ const [summaryLoading, setSummaryLoading] = useState(true);
const recordsRequestRef = useRef(0);
const filterOptionsRequestRef = useRef(0);
@@ -200,6 +204,29 @@ const AuditPage: React.FC = () => {
],
);
+ useEffect(() => {
+ let cancelled = false;
+ // Reset the loading flag whenever the filters change so a stale summary is
+ // not shown while the refreshed aggregate is still in flight. The
microtask
+ // mirrors the record-list effect so the flag update is not applied
synchronously.
+ void Promise.resolve().then(() => {
+ if (!cancelled) setSummaryLoading(true);
+ });
+ void getAuditSummary(activeFilter)
+ .then((value) => {
+ if (!cancelled) setSummary(value);
+ })
+ .catch(() => {
+ if (!cancelled) message.error('审计概览加载失败,请稍后重试');
+ })
+ .finally(() => {
+ if (!cancelled) setSummaryLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activeFilter, refreshKey]);
+
const { Text } = Typography;
const renderOperationType = (type: string) => {
@@ -461,6 +488,8 @@ const AuditPage: React.FC = () => {
</Flex>
</Flex>
+ <AuditSummaryCards summary={summary} loading={summaryLoading} />
+
{/* ─── Table ─── */}
<Card styles={{ body: { padding: 0 } }}>
<Table
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index d0a7f4f91..0619f7de9 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -1,5 +1,9 @@
-import { exportAuditLogs as exportAuditLogsApi, fetchAuditFilterOptions } from
'../api/audit';
-import type { AuditFilter, AuditFilterOptions } from '../api/audit';
+import {
+ exportAuditLogs as exportAuditLogsApi,
+ fetchAuditFilterOptions,
+ fetchAuditSummary,
+} from '../api/audit';
+import type { AuditFilter, AuditFilterOptions, AuditSummary } from
'../api/audit';
import { isMockMode } from './dataMode';
import * as opsApi from '../api/ops';
import type {
@@ -493,6 +497,35 @@ export async function exportAuditLogs(params: AuditFilter
= {}): Promise<string>
return formatAuditCsv(filterAuditRecords(params));
}
+export async function getAuditSummary(params: AuditFilter = {}):
Promise<AuditSummary> {
+ if (!isMockMode()) return fetchAuditSummary(params);
+ const records = filterAuditRecords(params);
+ const countBy = (field: 'operationType' | 'resourceType') =>
+ Array.from(
+ records.reduce((counts, record) => {
+ const name = record[field] || 'UNKNOWN';
+ counts.set(name, (counts.get(name) || 0) + 1);
+ return counts;
+ }, new Map<string, number>()),
+ ([name, count]) => ({ name, count }),
+ )
+ .sort((left, right) => right.count - left.count ||
left.name.localeCompare(right.name))
+ .slice(0, 8);
+ const resultCount = (name: string) =>
+ records.filter((record) => record.result.toUpperCase() === name).length;
+ const timestamps = records.map((record) => record.timestamp).sort();
+ return {
+ total: records.length,
+ successful: resultCount('SUCCESS'),
+ failed: resultCount('FAILED'),
+ partial: resultCount('PARTIAL'),
+ uniqueOperators: new Set(records.map((record) =>
record.operator).filter(Boolean)).size,
+ latestAt: timestamps[timestamps.length - 1] || null,
+ byOperation: countBy('operationType'),
+ byResourceType: countBy('resourceType'),
+ };
+}
+
export async function cleanupAuditLogs(beforeDays: number): Promise<number> {
if (isMockMode()) {
const cutoff = new Date(Date.now() - beforeDays * 24 * 60 * 60 * 1000);