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);

Reply via email to