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 730271b6 feat: add dynamic audit log filters (#1091)
730271b6 is described below

commit 730271b6439bbd7b000cffee1abb2e5a2b253e17
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 11:40:43 2026 +0800

    feat: add dynamic audit log filters (#1091)
---
 docs/api-spec.md                                   |  70 ++++++----
 .../rocketmq/studio/ops/audit/AuditController.java |  16 ++-
 .../studio/ops/audit/AuditFilterOptionsVO.java     |  21 ++-
 .../rocketmq/studio/ops/audit/AuditRepository.java |   4 +-
 .../rocketmq/studio/ops/audit/AuditService.java    |  26 ++--
 .../ops/audit/MybatisPlusAuditRepository.java      |  30 ++++
 .../studio/ops/audit/AuditControllerTest.java      |  42 ++++--
 .../studio/ops/audit/AuditServiceTest.java         |  57 +++++---
 .../ops/audit/MybatisPlusAuditRepositoryTest.java  |  34 ++++-
 web/src/api/audit.test.ts                          |  14 +-
 web/src/api/audit.ts                               |  12 ++
 web/src/api/ops.ts                                 |   2 +
 web/src/mock/audit.ts                              | 111 ++++++++++-----
 web/src/pages/ops/__tests__/AuditPage.test.tsx     |  70 +++++++++-
 web/src/pages/ops/audit.tsx                        | 152 ++++++++++++++++-----
 web/src/services/opsService.test.ts                |  47 +++++++
 web/src/services/opsService.ts                     |  24 +++-
 17 files changed, 588 insertions(+), 144 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index f83f2894..6ed89bc1 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -99,20 +99,21 @@
 | 56 | POST | `/api/system-alerts/acknowledge` | 确认告警 |
 | 57 | POST | `/api/system-alerts/clear-acknowledged` | 清除已确认告警 |
 | 58 | GET | `/api/audit-logs` | 审计日志列表 |
-| 59 | GET | `/api/audit-logs/export` | 导出审计日志 |
-| 60 | POST | `/api/audit-logs/cleanup` | 清理审计日志 |
-| 61 | GET | `/api/settings/general` | 获取通用设置 |
-| 62 | POST | `/api/settings/general/save` | 保存通用设置 |
-| 63 | GET | `/api/settings/datasources` | 数据源列表 |
-| 64 | POST | `/api/settings/datasources/create` | 创建数据源 |
-| 65 | POST | `/api/settings/datasources/update` | 更新数据源 |
-| 66 | POST | `/api/settings/datasources/delete` | 删除数据源 |
-| 67 | POST | `/api/settings/datasources/test` | 测试数据源连接 |
-| 68 | POST | `/api/ai/chat` | AI 对话(SSE) |
-| 69 | POST | `/api/ai/execute` | 执行 AI 指令 |
-| 70 | GET | `/api/ai/tools` | 可用工具列表 |
-| 71 | POST | `/api/ai/tools/:name/execute` | 执行只读 AI 工具 |
-| 72 | POST | `/api/metrics/query` | 查询监控指标数据 |
+| 59 | GET | `/api/audit-logs/filter-options` | 审计日志筛选项 |
+| 60 | GET | `/api/audit-logs/export` | 导出审计日志 |
+| 61 | POST | `/api/audit-logs/cleanup` | 清理审计日志 |
+| 62 | GET | `/api/settings/general` | 获取通用设置 |
+| 63 | POST | `/api/settings/general/save` | 保存通用设置 |
+| 64 | GET | `/api/settings/datasources` | 数据源列表 |
+| 65 | POST | `/api/settings/datasources/create` | 创建数据源 |
+| 66 | POST | `/api/settings/datasources/update` | 更新数据源 |
+| 67 | POST | `/api/settings/datasources/delete` | 删除数据源 |
+| 68 | POST | `/api/settings/datasources/test` | 测试数据源连接 |
+| 69 | POST | `/api/ai/chat` | AI 对话(SSE) |
+| 70 | POST | `/api/ai/execute` | 执行 AI 指令 |
+| 71 | GET | `/api/ai/tools` | 可用工具列表 |
+| 72 | POST | `/api/ai/tools/:name/execute` | 执行只读 AI 工具 |
+| 73 | POST | `/api/metrics/query` | 查询监控指标数据 |
 
 ## 通用响应格式
 
@@ -1439,7 +1440,7 @@ POST /api/system-alerts/clear-acknowledged
 ### 13.1 获取审计日志列表
 
 ```
-GET 
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationType={type}&startDate={start}&endDate={end}&result={result}
+GET 
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationType={type}&resourceType={resourceType}&clusterId={clusterId}&startDate={start}&endDate={end}&result={result}
 ```
 
 **Query Parameters:**
@@ -1450,9 +1451,11 @@ GET 
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationTyp
 | `pageSize` | `number` | 否 | 每页条数,默认 20 |
 | `search` | `string` | 否 | 搜索(匹配 operator / target) |
 | `operationType` | `string` | 否 | 操作类型过滤 |
+| `resourceType` | `string` | 否 | 资源类型过滤 |
+| `clusterId` | `string` | 否 | 集群 ID 过滤 |
 | `startDate` | `string` | 否 | 开始日期 (YYYY-MM-DD) |
 | `endDate` | `string` | 否 | 结束日期 (YYYY-MM-DD) |
-| `result` | `string` | 否 | 结果过滤: `success` / `failure` |
+| `result` | `string` | 否 | 结果过滤,传入筛选项接口返回的原始值 |
 
 `startDate` 或 `endDate` 格式错误,以及 `startDate` 晚于 `endDate` 时,接口返回 HTTP 400。
 
@@ -1460,8 +1463,10 @@ GET 
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationTyp
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
-| `records` | `AuditRecord[]` | 记录列表 |
+| `items` | `AuditRecord[]` | 记录列表 |
 | `total` | `number` | 总条数 |
+| `page` | `number` | 当前页码 |
+| `size` | `number` | 每页条数 |
 
 #### AuditRecord
 
@@ -1470,16 +1475,35 @@ GET 
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationTyp
 | `id` | `string` | 记录 ID |
 | `timestamp` | `string` | 操作时间(`YYYY-MM-DD HH:mm:ss`) |
 | `operator` | `string` | 操作人(如 `admin`, `ops-zhang`, `system`) |
-| `operationType` | `string` | 操作类型: `创建Topic` / `删除Topic` / `修改配置` / `重置位点` / 
`ACL变更` / `重启Broker` / `删除消费组` |
+| `operationType` | `string` | 持久化的操作类型代码,如 `CREATE_TOPIC` / `RESET_OFFSET` |
+| `resourceType` | `string` | 资源类型代码,如 `TOPIC` / `GROUP` / `CLUSTER` |
 | `target` | `string` | 操作对象 |
+| `clusterId` | `string` | 所属集群 ID,无集群上下文时为 `null` |
 | `detail` | `string` | 详细描述 |
-| `ipAddress` | `string` | 操作 IP 地址 |
-| `result` | `string` | 结果: `success` / `failure` |
+| `result` | `string` | 持久化的结果代码,如 `SUCCESS` / `FAILED` / `FAILURE` / 
`PARTIAL` |
+| `errorMessage` | `string` | 失败或部分成功时的错误信息 |
 
-### 13.2 导出审计日志
+### 13.2 获取审计日志筛选项
 
 ```
-GET 
/api/audit-logs/export?search={search}&operationType={type}&startDate={start}&endDate={end}&result={result}
+GET /api/audit-logs/filter-options
+```
+
+**Response `data`:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `operationTypes` | `string[]` | 数据库中已存在的操作类型原始值 |
+| `resourceTypes` | `string[]` | 数据库中已存在的资源类型原始值 |
+| `clusterIds` | `string[]` | 数据库中已存在的非空集群 ID |
+| `results` | `string[]` | 数据库中已存在的结果原始值 |
+
+筛选项保留数据库中的原始值,请将选中值原样传入列表或导出接口。
+
+### 13.3 导出审计日志
+
+```
+GET 
/api/audit-logs/export?search={search}&operationType={type}&resourceType={resourceType}&clusterId={clusterId}&startDate={start}&endDate={end}&result={result}
 ```
 
 查询参数与列表接口相同,但不包含 `page` 和 `pageSize`。接口返回全部匹配记录,不受当前表格分页影响。
@@ -1488,7 +1512,7 @@ GET 
/api/audit-logs/export?search={search}&operationType={type}&startDate={start
 
 `startDate` 或 `endDate` 格式错误,以及 `startDate` 晚于 `endDate` 时,接口返回 HTTP 400。
 
-### 13.3 清理审计日志
+### 13.4 清理审计日志
 
 ```
 POST /api/audit-logs/cleanup
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 21a6da68..ddac5f9f 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
@@ -42,21 +42,31 @@ public class AuditController {
             @RequestParam(defaultValue = "20") int pageSize,
             @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.queryLogs(page, pageSize, search,
-                operationType, startDate, endDate, result));
+        return Result.ok(auditService.queryLogs(page, pageSize, search, 
operationType,
+                resourceType, clusterId, startDate, endDate, result));
+    }
+
+    @GetMapping("/filter-options")
+    public Result<AuditFilterOptionsVO> getFilterOptions() {
+        return Result.ok(auditService.getFilterOptions());
     }
 
     @GetMapping("/export")
     public Result<String> exportLogs(
             @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.exportLogs(search, operationType, 
startDate, endDate, result));
+        return Result.ok(auditService.exportLogs(search, operationType, 
resourceType,
+                clusterId, startDate, endDate, result));
     }
 
     @PostMapping("/cleanup")
diff --git a/web/src/api/audit.ts 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditFilterOptionsVO.java
similarity index 65%
copy from web/src/api/audit.ts
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditFilterOptionsVO.java
index 26b55734..43a6b951 100644
--- a/web/src/api/audit.ts
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditFilterOptionsVO.java
@@ -14,13 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.rocketmq.studio.ops.audit;
 
-import client from './client';
-import type { AuditQuery } from './ops';
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
 
-export type AuditFilter = Omit<AuditQuery, 'page' | 'pageSize'>;
+import java.util.List;
 
-export async function exportAuditLogs(params?: AuditFilter): Promise<string> {
-  const res = await client.get<{ data: string }>('/audit-logs/export', { 
params });
-  return res.data.data;
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuditFilterOptionsVO {
+    private List<String> operationTypes;
+    private List<String> resourceTypes;
+    private List<String> clusterIds;
+    private List<String> results;
 }
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 6bd5e1da..953c3bd0 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
@@ -16,15 +16,17 @@
  */
 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);
 
+    AuditFilterOptionsVO findFilterOptions();
+
     void save(AuditRecordVO record);
 
     int deleteBefore(LocalDateTime cutoff);
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 61fbdd1d..f9a733b3 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
@@ -43,19 +43,26 @@ public class AuditService {
 
 
     public PageResult<AuditRecordVO> queryLogs(int page, int pageSize, String 
search,
-                                             String operationType, String 
startDate,
+                                             String operationType, String 
resourceType,
+                                             String clusterId, String 
startDate,
                                              String endDate, String result) {
         validatePagination(page, pageSize);
         log.info("Querying audit logs, page={}, pageSize={}, search={}, 
operationType={}, result={}",
                 page, pageSize, search, operationType, result);
 
-        return findPage(search, operationType, startDate, endDate, result, 
page, pageSize);
+        return findPage(search, operationType, resourceType, clusterId,
+                startDate, endDate, result, page, pageSize);
     }
 
-    public String exportLogs(String search, String operationType, String 
startDate,
-                             String endDate, String result) {
+    public AuditFilterOptionsVO getFilterOptions() {
+        return auditRepository.findFilterOptions();
+    }
+
+    public String exportLogs(String search, String operationType, String 
resourceType,
+                             String clusterId, String startDate, String 
endDate, String result) {
         PageResult<AuditRecordVO> page = findPage(
-                search, operationType, startDate, endDate, result, 1, 
MAX_EXPORT_RECORDS);
+                search, operationType, resourceType, clusterId,
+                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");
@@ -108,14 +115,17 @@ public class AuditService {
         }
     }
 
-    private PageResult<AuditRecordVO> findPage(String search, String 
operationType, String startDate,
-                                               String endDate, String result, 
int page, int pageSize) {
+    private PageResult<AuditRecordVO> findPage(String search, String 
operationType,
+                                               String resourceType, String 
clusterId,
+                                               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.findPage(search, operationType, start, end, 
result, page, pageSize);
+        return auditRepository.findPage(search, operationType, resourceType, 
clusterId,
+                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 e6f2dc35..424d62b7 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
@@ -27,6 +27,8 @@ import lombok.RequiredArgsConstructor;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 /** MySQL-backed audit repository (rmq_operation_audit). */
@@ -38,6 +40,7 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
 
     @Override
     public PageResult<AuditRecordVO> findPage(String search, String 
operationType,
+                                              String resourceType, String 
clusterId,
                                               LocalDateTime startDate, 
LocalDateTime endDate,
                                               String result, int page, int 
pageSize) {
         QueryWrapper<RmqOperationAudit> query = new 
QueryWrapper<RmqOperationAudit>()
@@ -46,6 +49,8 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
                         .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, "operated_at", startDate)
                 .le(endDate != null, "operated_at", endDate)
                 .eq(StringUtils.hasText(result), "result", result)
@@ -58,6 +63,20 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
         return PageResult.of(records, resultPage.getTotal(), page, pageSize);
     }
 
+    @Override
+    public AuditFilterOptionsVO findFilterOptions() {
+        List<Map<String, Object>> values = auditMapper.selectMaps(
+                new QueryWrapper<RmqOperationAudit>()
+                        .select("operation", "resource_type", "cluster_id", 
"result")
+                        .groupBy("operation", "resource_type", "cluster_id", 
"result"));
+        return AuditFilterOptionsVO.builder()
+                .operationTypes(findDistinctValues(values, "operation"))
+                .resourceTypes(findDistinctValues(values, "resource_type"))
+                .clusterIds(findDistinctValues(values, "cluster_id"))
+                .results(findDistinctValues(values, "result"))
+                .build();
+    }
+
     @Override
     public void save(AuditRecordVO record) {
         RmqOperationAudit entity = new RmqOperationAudit();
@@ -79,6 +98,17 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
                 new QueryWrapper<RmqOperationAudit>().lt("operated_at", 
cutoff)));
     }
 
+    private List<String> findDistinctValues(List<Map<String, Object>> rows, 
String column) {
+        return rows.stream()
+                .map(row -> row.get(column))
+                .filter(Objects::nonNull)
+                .map(Object::toString)
+                .filter(StringUtils::hasText)
+                .distinct()
+                .sorted()
+                .toList();
+    }
+
     private static AuditRecordVO toVO(RmqOperationAudit entity) {
         AuditRecordVO vo = new AuditRecordVO();
         vo.setId(entity.getId() == null ? null : 
String.valueOf(entity.getId()));
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 453a5793..aefc1eba 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
@@ -61,7 +61,7 @@ class AuditControllerTest {
                 .result("SUCCESS")
                 .build();
         when(auditService.queryLogs(eq(2), eq(10), eq("topic"), eq("DELETE"),
-                eq("2026-07-01"), eq("2026-07-24"), eq("SUCCESS")))
+                eq("TOPIC"), eq("prod-cn"), eq("2026-07-01"), 
eq("2026-07-24"), eq("SUCCESS")))
                 .thenReturn(PageResult.of(List.of(record), 1, 2, 10));
 
         mockMvc.perform(get("/api/audit-logs")
@@ -69,6 +69,8 @@ class AuditControllerTest {
                         .param("pageSize", "10")
                         .param("search", "topic")
                         .param("operationType", "DELETE")
+                        .param("resourceType", "TOPIC")
+                        .param("clusterId", "prod-cn")
                         .param("startDate", "2026-07-01")
                         .param("endDate", "2026-07-24")
                         .param("result", "SUCCESS"))
@@ -79,7 +81,7 @@ class AuditControllerTest {
                 .andExpect(jsonPath("$.data.total").value(1));
 
         verify(auditService).queryLogs(eq(2), eq(10), eq("topic"), 
eq("DELETE"),
-                eq("2026-07-01"), eq("2026-07-24"), eq("SUCCESS"));
+                eq("TOPIC"), eq("prod-cn"), eq("2026-07-01"), 
eq("2026-07-24"), eq("SUCCESS"));
     }
 
     @Test
@@ -146,7 +148,8 @@ class AuditControllerTest {
 
     @Test
     void queryLogsShouldUseDefaultPagination() throws Exception {
-        when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), isNull()))
+        when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(),
+                isNull(), isNull(), isNull()))
                 .thenReturn(PageResult.of(List.of(), 0, 1, 20));
 
         mockMvc.perform(get("/api/audit-logs"))
@@ -155,18 +158,21 @@ class AuditControllerTest {
                 .andExpect(jsonPath("$.data.page").value(1))
                 .andExpect(jsonPath("$.data.size").value(20));
 
-        verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), isNull());
+        verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(),
+                isNull(), isNull(), isNull());
     }
 
     @Test
     void exportLogsShouldForwardFilters() throws Exception {
         String csv = 
"\uFEFFtimestamp,operator\r\n\"2026-08-01T09:30\",\"admin\"\r\n";
-        when(auditService.exportLogs(eq("topic"), eq("DELETE"), 
eq("2026-08-01"),
-                eq("2026-08-02"), eq("SUCCESS"))).thenReturn(csv);
+        when(auditService.exportLogs(eq("topic"), eq("DELETE"), eq("TOPIC"), 
eq("prod-cn"),
+                eq("2026-08-01"), eq("2026-08-02"), 
eq("SUCCESS"))).thenReturn(csv);
 
         mockMvc.perform(get("/api/audit-logs/export")
                         .param("search", "topic")
                         .param("operationType", "DELETE")
+                        .param("resourceType", "TOPIC")
+                        .param("clusterId", "prod-cn")
                         .param("startDate", "2026-08-01")
                         .param("endDate", "2026-08-02")
                         .param("result", "SUCCESS"))
@@ -174,7 +180,27 @@ class AuditControllerTest {
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data").value(csv));
 
-        verify(auditService).exportLogs(eq("topic"), eq("DELETE"), 
eq("2026-08-01"),
-                eq("2026-08-02"), eq("SUCCESS"));
+        verify(auditService).exportLogs(eq("topic"), eq("DELETE"), 
eq("TOPIC"), eq("prod-cn"),
+                eq("2026-08-01"), eq("2026-08-02"), eq("SUCCESS"));
+    }
+
+    @Test
+    void getFilterOptionsShouldReturnPersistedFacets() throws Exception {
+        AuditFilterOptionsVO options = AuditFilterOptionsVO.builder()
+                .operationTypes(List.of("CREATE_TOPIC", "DELETE_TOPIC"))
+                .resourceTypes(List.of("TOPIC"))
+                .clusterIds(List.of("prod-cn"))
+                .results(List.of("FAILED", "SUCCESS"))
+                .build();
+        when(auditService.getFilterOptions()).thenReturn(options);
+
+        mockMvc.perform(get("/api/audit-logs/filter-options"))
+                .andExpect(status().isOk())
+                
.andExpect(jsonPath("$.data.operationTypes[0]").value("CREATE_TOPIC"))
+                .andExpect(jsonPath("$.data.resourceTypes[0]").value("TOPIC"))
+                .andExpect(jsonPath("$.data.clusterIds[0]").value("prod-cn"))
+                .andExpect(jsonPath("$.data.results[0]").value("FAILED"));
+
+        verify(auditService).getFilterOptions();
     }
 }
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 62efce2d..6ac8ace3 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
@@ -66,45 +66,52 @@ class AuditServiceTest {
     @Test
     void queryLogsDelegatesPaginationAndFiltersToRepository() {
         AuditRecordVO record = 
AuditRecordVO.builder().operationType("CREATE").build();
-        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));
+        when(auditRepository.findPage(eq("topic-a"), eq("CREATE"), 
eq("TOPIC"), eq("prod-cn"),
+                isNull(), isNull(), eq("SUCCESS"), eq(2), eq(20)))
+                .thenReturn(PageResult.of(List.of(record), 21, 2, 20));
 
         PageResult<AuditRecordVO> result = auditService.queryLogs(
-                2, 20, "topic-a", "CREATE", null, null, "SUCCESS");
+                2, 20, "topic-a", "CREATE", "TOPIC", "prod-cn", null, null, 
"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));
+        verify(auditRepository).findPage(eq("topic-a"), eq("CREATE"), 
eq("TOPIC"), eq("prod-cn"),
+                isNull(), isNull(), eq("SUCCESS"), eq(2), eq(20));
     }
 
     @Test
     void queryLogsParsesDateRangeBeforeDelegating() {
-        when(auditRepository.findPage(isNull(), isNull(), 
any(LocalDateTime.class), any(LocalDateTime.class),
-                isNull(), eq(1), eq(10))).thenReturn(PageResult.empty(1, 10));
+        when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
+                any(LocalDateTime.class), any(LocalDateTime.class), isNull(), 
eq(1), eq(10)))
+                .thenReturn(PageResult.empty(1, 10));
 
-        auditService.queryLogs(1, 10, null, null, "2026-08-01", "2026-08-02", 
null);
+        auditService.queryLogs(1, 10, null, null, null, null,
+                "2026-08-01", "2026-08-02", null);
 
         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));
+        verify(auditRepository).findPage(isNull(), isNull(), 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 queryLogsRejectsInvalidPageBounds() {
-        assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null, 
null, null, null))
+        assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null, 
null, null,
+                null, null, null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("page must be greater than 0");
-        assertThatThrownBy(() -> auditService.queryLogs(1, 101, null, null, 
null, null, null))
+        assertThatThrownBy(() -> auditService.queryLogs(1, 101, null, null, 
null, null,
+                null, null, null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("pageSize must be between 1 and 100");
     }
 
     @Test
     void queryLogsRejectsInvalidDateRange() {
-        assertThatThrownBy(() -> auditService.queryLogs(1, 10, null, null, 
"2026-08-02", "2026-08-01", null))
+        assertThatThrownBy(() -> auditService.queryLogs(1, 10, null, null, 
null, null,
+                "2026-08-02", "2026-08-01", null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("startDate must not be after endDate");
     }
@@ -122,11 +129,12 @@ class AuditServiceTest {
                 .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)))
+        when(auditRepository.findPage(eq("topic"), eq("DELETE"), eq("TOPIC"), 
eq("prod-cn"),
+                any(LocalDateTime.class), any(LocalDateTime.class), 
eq("FAILED"), eq(1), eq(10_000)))
                 .thenReturn(PageResult.of(List.of(record), 1, 1, 10_000));
 
-        String csv = auditService.exportLogs("topic", "DELETE", "2026-08-01", 
"2026-08-02", "FAILED");
+        String csv = auditService.exportLogs("topic", "DELETE", "TOPIC", 
"prod-cn",
+                "2026-08-01", "2026-08-02", "FAILED");
 
         
assertThat(csv).contains("resourceType,target,clusterId,detail,result,errorMessage")
                 
.contains("\"'=cmd\",\"DELETE\",\"TOPIC\",\"topic,a\",\"prod-cn\"")
@@ -135,14 +143,29 @@ class AuditServiceTest {
 
     @Test
     void exportLogsRejectsResultsBeyondBound() {
-        when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(), 
isNull(), eq(1), eq(10_000)))
+        when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(), 
isNull(), isNull(),
+                isNull(), eq(1), eq(10_000)))
                 .thenReturn(PageResult.of(List.of(), 10_001, 1, 10_000));
 
-        assertThatThrownBy(() -> auditService.exportLogs(null, null, null, 
null, null))
+        assertThatThrownBy(() -> auditService.exportLogs(null, null, null, 
null, null, null, null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("Audit log export exceeds the maximum of 10000 
records; narrow the filters");
     }
 
+    @Test
+    void getFilterOptionsReturnsRepositoryValues() {
+        AuditFilterOptionsVO options = AuditFilterOptionsVO.builder()
+                .operationTypes(List.of("CREATE_TOPIC"))
+                .resourceTypes(List.of("TOPIC"))
+                .clusterIds(List.of("prod-cn"))
+                .results(List.of("SUCCESS"))
+                .build();
+        when(auditRepository.findFilterOptions()).thenReturn(options);
+
+        assertThat(auditService.getFilterOptions()).isSameAs(options);
+        verify(auditRepository).findFilterOptions();
+    }
+
     @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 46fe7f09..c900b779 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
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.audit;
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.apache.rocketmq.studio.common.domain.PageResult;
@@ -31,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
@@ -64,10 +66,11 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), 
any(Wrapper.class))).thenReturn(mapperPage);
 
         PageResult<AuditRecordVO> result = repository.findPage(
-                "orders", "DELETE_TOPIC", null, null, "FAILED", 2, 25);
+                "orders", "DELETE_TOPIC", "TOPIC", "prod-cn", null, null, 
"FAILED", 2, 25);
 
         ArgumentCaptor<IPage<RmqOperationAudit>> pageCaptor = 
ArgumentCaptor.forClass(IPage.class);
-        verify(auditMapper).selectPage(pageCaptor.capture(), 
any(Wrapper.class));
+        ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
+        verify(auditMapper).selectPage(pageCaptor.capture(), 
queryCaptor.capture());
         assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2);
         assertThat(pageCaptor.getValue().getSize()).isEqualTo(25);
         assertThat(result.getTotal()).isEqualTo(126);
@@ -76,5 +79,32 @@ class MybatisPlusAuditRepositoryTest {
         assertThat(record.getResourceType()).isEqualTo("TOPIC");
         assertThat(record.getClusterId()).isEqualTo("prod-cn");
         assertThat(record.getErrorMessage()).isEqualTo("denied");
+        assertThat(queryCaptor.getValue().getSqlSegment())
+                .contains("operation", "resource_type", "cluster_id", 
"result");
     }
+
+    @Test
+    void findFilterOptionsPreservesPersistedValuesFromOneQuery() {
+        when(auditMapper.selectMaps(any(Wrapper.class))).thenReturn(List.of(
+                Map.of("operation", "DELETE_TOPIC", "resource_type", "TOPIC",
+                        "cluster_id", "prod-cn", "result", "SUCCESS"),
+                Map.of("operation", " CREATE_TOPIC ", "resource_type", "GROUP",
+                        "cluster_id", "prod-sh", "result", "FAILED"),
+                Map.of("operation", "DELETE_TOPIC", "resource_type", "TOPIC",
+                        "cluster_id", "", "result", "PARTIAL")));
+
+        AuditFilterOptionsVO options = repository.findFilterOptions();
+
+        assertThat(options.getOperationTypes()).containsExactly(" CREATE_TOPIC 
", "DELETE_TOPIC");
+        assertThat(options.getResourceTypes()).containsExactly("GROUP", 
"TOPIC");
+        assertThat(options.getClusterIds()).containsExactly("prod-cn", 
"prod-sh");
+        assertThat(options.getResults()).containsExactly("FAILED", "PARTIAL", 
"SUCCESS");
+        ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
+        verify(auditMapper).selectMaps(queryCaptor.capture());
+        assertThat(((QueryWrapper<RmqOperationAudit>) 
queryCaptor.getValue()).getSqlSelect())
+                .contains("operation", "resource_type", "cluster_id", 
"result");
+        assertThat(queryCaptor.getValue().getSqlSegment())
+                .contains("GROUP BY 
operation,resource_type,cluster_id,result");
+    }
+
 }
diff --git a/web/src/api/audit.test.ts b/web/src/api/audit.test.ts
index 5e6112a6..723b7574 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 } from './audit';
+import { exportAuditLogs, fetchAuditFilterOptions } from './audit';
 import client from './client';
 import { cleanupAuditLogs, listAuditRecords } from './ops';
 
@@ -28,6 +28,18 @@ afterEach(() => {
 });
 
 describe('audit log API', () => {
+  it('returns persisted values for audit filter options', async () => {
+    const options = {
+      operationTypes: ['CREATE_TOPIC', 'DELETE_TOPIC'],
+      resourceTypes: ['TOPIC'],
+      clusterIds: ['prod-cn'],
+      results: ['FAILED', 'SUCCESS'],
+    };
+    mock.onGet('/audit-logs/filter-options').reply(200, { code: 200, data: 
options });
+
+    await expect(fetchAuditFilterOptions()).resolves.toEqual(options);
+  });
+
   it('uses the backend PageResult contract for filtered audit queries', async 
() => {
     mock.onGet('/audit-logs').reply((config) => {
       expect(config.params).toEqual({ page: 2, pageSize: 10, result: 'SUCCESS' 
});
diff --git a/web/src/api/audit.ts b/web/src/api/audit.ts
index 26b55734..f19a1d1a 100644
--- a/web/src/api/audit.ts
+++ b/web/src/api/audit.ts
@@ -20,6 +20,18 @@ import type { AuditQuery } from './ops';
 
 export type AuditFilter = Omit<AuditQuery, 'page' | 'pageSize'>;
 
+export interface AuditFilterOptions {
+  operationTypes: string[];
+  resourceTypes: string[];
+  clusterIds: string[];
+  results: string[];
+}
+
+export async function fetchAuditFilterOptions(): Promise<AuditFilterOptions> {
+  const res = await client.get<{ data: AuditFilterOptions 
}>('/audit-logs/filter-options');
+  return res.data.data;
+}
+
 export async function exportAuditLogs(params?: AuditFilter): Promise<string> {
   const res = await client.get<{ data: string }>('/audit-logs/export', { 
params });
   return res.data.data;
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 039ecbfb..8a0e4a57 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -51,6 +51,8 @@ export interface AuditQuery {
   pageSize?: number;
   search?: string;
   operationType?: string;
+  resourceType?: string;
+  clusterId?: string;
   startDate?: string;
   endDate?: string;
   result?: string;
diff --git a/web/src/mock/audit.ts b/web/src/mock/audit.ts
index 59f9ab27..45b4b81d 100644
--- a/web/src/mock/audit.ts
+++ b/web/src/mock/audit.ts
@@ -20,181 +20,220 @@ export const mockAuditRecords = [
     id: 'audit-001',
     timestamp: '2026-07-03 09:45:12',
     operator: 'admin',
-    operationType: '创建Topic',
+    operationType: 'CREATE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-trade-order-topic',
     detail: '创建 Topic: rmq-trade-order-topic,队列数 16,权限 6',
     ipAddress: '10.0.12.45',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-002',
     timestamp: '2026-07-03 09:32:08',
     operator: 'ops-zhang',
-    operationType: '修改配置',
+    operationType: 'UPDATE_GROUP',
+    resourceType: 'GROUP',
+    clusterId: 'prod-cn',
     target: 'rmq-payment-group',
     detail: '修改消费组 rmq-payment-group 的消费模式为 CLUSTERING',
     ipAddress: '10.0.15.22',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-003',
     timestamp: '2026-07-03 08:17:55',
     operator: 'ops-li',
-    operationType: '重置位点',
+    operationType: 'RESET_OFFSET',
+    resourceType: 'GROUP',
+    clusterId: 'prod-cn',
     target: 'rmq-log-consumer-group',
     detail: '重置消费组 rmq-log-consumer-group 的位点到 2026-07-02 00:00:00',
     ipAddress: '10.0.8.101',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-004',
     timestamp: '2026-07-03 07:58:33',
     operator: 'admin',
-    operationType: '删除Topic',
+    operationType: 'DELETE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'test-cn',
     target: 'rmq-test-temp-topic',
     detail: '删除测试用 Topic: rmq-test-temp-topic',
     ipAddress: '10.0.12.45',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-005',
     timestamp: '2026-07-03 07:22:19',
     operator: 'ops-wang',
-    operationType: 'ACL变更',
+    operationType: 'UPDATE_ACL',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-secure-topic',
     detail: '为 Topic rmq-secure-topic 添加 ACL 规则,允许 user: readonly 读取',
     ipAddress: '10.0.20.88',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-006',
     timestamp: '2026-07-02 22:45:01',
     operator: 'system',
-    operationType: '重启Broker',
+    operationType: 'RESTART_BROKER',
+    resourceType: 'CLUSTER',
+    clusterId: 'prod-hz',
     target: 'broker-cn-hangzhou-02',
     detail: '自动重启异常 Broker 节点 broker-cn-hangzhou-02',
     ipAddress: '10.0.1.2',
-    result: 'failure' as const,
+    result: 'FAILED' as const,
+    errorMessage: 'Broker did not become ready before the timeout',
   },
   {
     id: 'audit-007',
     timestamp: '2026-07-02 20:11:47',
     operator: 'ops-zhang',
-    operationType: '修改配置',
+    operationType: 'UPDATE_BROKER_CONFIG',
+    resourceType: 'CLUSTER',
+    clusterId: 'prod-sh',
     target: 'broker-cn-shanghai-01',
     detail: '修改 Broker broker-cn-shanghai-01 的 flushDiskType 为 ASYNC_FLUSH',
     ipAddress: '10.0.15.22',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-008',
     timestamp: '2026-07-02 18:33:28',
     operator: 'ops-li',
-    operationType: '创建Topic',
+    operationType: 'CREATE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-notification-topic',
     detail: '创建 Topic: rmq-notification-topic,队列数 8,权限 6',
     ipAddress: '10.0.8.101',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-009',
     timestamp: '2026-07-02 16:05:14',
     operator: 'admin',
-    operationType: '删除消费组',
+    operationType: 'DELETE_GROUP',
+    resourceType: 'GROUP',
+    clusterId: 'prod-cn',
     target: 'rmq-legacy-consumer-group',
     detail: '删除已废弃的消费组 rmq-legacy-consumer-group',
     ipAddress: '10.0.12.45',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-010',
     timestamp: '2026-07-02 14:28:56',
     operator: 'ops-wang',
-    operationType: 'ACL变更',
+    operationType: 'UPDATE_ACL',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-admin-topic',
     detail: '更新 Topic rmq-admin-topic 的 ACL 规则,移除 user: guest 的访问权限',
     ipAddress: '10.0.20.88',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-011',
     timestamp: '2026-07-02 11:52:09',
     operator: 'ops-zhang',
-    operationType: '重置位点',
+    operationType: 'RESET_OFFSET',
+    resourceType: 'GROUP',
+    clusterId: 'prod-cn',
     target: 'rmq-order-consumer-group',
     detail: '重置消费组 rmq-order-consumer-group 的位点到 2026-07-01 12:00:00',
     ipAddress: '10.0.15.22',
-    result: 'failure' as const,
+    result: 'FAILED' as const,
+    errorMessage: 'Consumer group is not online',
   },
   {
     id: 'audit-012',
     timestamp: '2026-07-02 10:15:42',
     operator: 'system',
-    operationType: '重启Broker',
+    operationType: 'RESTART_BROKER',
+    resourceType: 'CLUSTER',
+    clusterId: 'prod-bj',
     target: 'broker-cn-beijing-03',
     detail: '手动重启 Broker 节点 broker-cn-beijing-03 以应用配置变更',
     ipAddress: '10.0.1.1',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-013',
     timestamp: '2026-07-01 23:41:18',
     operator: 'ops-li',
-    operationType: '创建Topic',
+    operationType: 'CREATE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-metrics-topic',
     detail: '创建 Topic: rmq-metrics-topic,队列数 32,权限 6',
     ipAddress: '10.0.8.101',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-014',
     timestamp: '2026-07-01 19:08:35',
     operator: 'admin',
-    operationType: '修改配置',
+    operationType: 'UPDATE_CLUSTER_CONFIG',
+    resourceType: 'CLUSTER',
+    clusterId: 'prod-cn',
     target: 'rmq-cluster-config',
     detail: '更新集群配置:开启消息轨迹追踪功能',
     ipAddress: '10.0.12.45',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-015',
     timestamp: '2026-07-01 15:27:50',
     operator: 'ops-wang',
-    operationType: '删除Topic',
+    operationType: 'DELETE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-deprecated-topic',
     detail: '删除已废弃 Topic: rmq-deprecated-topic',
     ipAddress: '10.0.20.88',
-    result: 'failure' as const,
+    result: 'FAILED' as const,
+    errorMessage: 'Topic still has active producers',
   },
   {
     id: 'audit-016',
     timestamp: '2026-07-01 13:55:23',
     operator: 'ops-zhang',
-    operationType: 'ACL变更',
+    operationType: 'UPDATE_ACL',
+    resourceType: 'TOPIC',
+    clusterId: 'prod-cn',
     target: 'rmq-billing-topic',
     detail: '为 Topic rmq-billing-topic 创建新的 ACL 规则,限制仅 admin 组可写入',
     ipAddress: '10.0.15.22',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-017',
     timestamp: '2026-07-01 10:33:07',
     operator: 'ops-li',
-    operationType: '删除消费组',
+    operationType: 'DELETE_GROUP',
+    resourceType: 'GROUP',
+    clusterId: 'prod-cn',
     target: 'rmq-old-consumer-group',
     detail: '删除已迁移的旧消费组 rmq-old-consumer-group',
     ipAddress: '10.0.8.101',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
   {
     id: 'audit-018',
     timestamp: '2026-07-01 08:19:44',
     operator: 'system',
-    operationType: '重启Broker',
+    operationType: 'RESTART_BROKER',
+    resourceType: 'CLUSTER',
+    clusterId: 'prod-sz',
     target: 'broker-cn-shenzhen-01',
     detail: '自动重启异常 Broker 节点 broker-cn-shenzhen-01,磁盘 IO 超时',
     ipAddress: '10.0.1.3',
-    result: 'success' as const,
+    result: 'SUCCESS' as const,
   },
 ] as const;
 
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx 
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index 663f0a35..1edbc099 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -27,6 +27,7 @@ import AuditPage from '../audit';
 vi.mock('../../../services/opsService', () => ({
   cleanupAuditLogs: vi.fn(),
   exportAuditLogs: vi.fn(),
+  getAuditFilterOptions: vi.fn(),
   listAuditRecords: vi.fn(),
 }));
 
@@ -59,13 +60,19 @@ describe('Audit page', () => {
   });
 
   beforeEach(() => {
+    vi.mocked(opsService.getAuditFilterOptions).mockResolvedValue({
+      operationTypes: ['CREATE_TOPIC', 'RESET_OFFSET'],
+      resourceTypes: ['CONSUMER_GROUP', 'TOPIC'],
+      clusterIds: ['prod-cn', 'prod-sh'],
+      results: ['FAILED', 'PARTIAL', 'SUCCESS'],
+    });
     vi.mocked(opsService.listAuditRecords).mockResolvedValue({
       items: [
         {
           id: 'audit-1',
           timestamp: '2026-08-01 10:00:00',
           operator: 'admin',
-          operationType: '删除Topic',
+          operationType: 'DELETE_TOPIC',
           resourceType: 'TOPIC',
           target: 'topic-a',
           clusterId: 'prod-cn',
@@ -111,6 +118,8 @@ describe('Audit page', () => {
       expect(opsService.exportAuditLogs).toHaveBeenCalledWith({
         search: 'topic-a',
         operationType: undefined,
+        resourceType: undefined,
+        clusterId: undefined,
         startDate: undefined,
         endDate: undefined,
         result: undefined,
@@ -122,4 +131,63 @@ describe('Audit page', () => {
     expect(clickSpy).toHaveBeenCalledTimes(1);
     expect(revokeObjectURL).toHaveBeenCalledWith('blob:audit');
   });
+
+  it('loads persisted filter values and forwards their original codes', async 
() => {
+    const user = userEvent.setup();
+    renderWithProviders(<AuditPage />);
+
+    expect(await screen.findByText('topic-a')).toBeInTheDocument();
+    await user.click(screen.getByRole('combobox', { name: '操作类型' }));
+    await user.click(
+      await screen.findByText('CREATE TOPIC', { selector: 
'.ant-select-item-option-content' }),
+    );
+    expect(screen.getByText('SUCCESS')).toBeInTheDocument();
+    await user.click(screen.getByRole('combobox', { name: '资源类型' }));
+    await user.click(
+      await screen.findByText('CONSUMER GROUP', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    await user.click(screen.getByRole('combobox', { name: '集群' }));
+    await user.click(
+      await screen.findByText('prod-sh', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    await waitFor(() =>
+      expect(opsService.listAuditRecords).toHaveBeenLastCalledWith({
+        page: 1,
+        pageSize: 20,
+        search: undefined,
+        operationType: 'CREATE_TOPIC',
+        resourceType: 'CONSUMER_GROUP',
+        clusterId: 'prod-sh',
+        startDate: undefined,
+        endDate: undefined,
+        result: undefined,
+      }),
+    );
+  });
+
+  it('still loads audit records when filter options cannot be loaded', async 
() => {
+    vi.mocked(opsService.getAuditFilterOptions).mockRejectedValueOnce(new 
Error('unavailable'));
+
+    renderWithProviders(<AuditPage />);
+
+    expect(await screen.findByText('topic-a')).toBeInTheDocument();
+    expect(opsService.listAuditRecords).toHaveBeenCalled();
+  });
+
+  it('refreshes filter options after audit logs are cleaned up', async () => {
+    const user = userEvent.setup();
+    vi.mocked(opsService.cleanupAuditLogs).mockResolvedValue(3);
+
+    renderWithProviders(<AuditPage />);
+
+    expect(await screen.findByText('topic-a')).toBeInTheDocument();
+    expect(opsService.getAuditFilterOptions).toHaveBeenCalledTimes(1);
+    await user.click(screen.getByRole('button', { name: '清理日志' }));
+    await user.click(await screen.findByRole('button', { name: '确认清理' }));
+
+    await waitFor(() => 
expect(opsService.getAuditFilterOptions).toHaveBeenCalledTimes(2));
+  });
 });
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index e18bd638..808b88f6 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -37,39 +37,44 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
-import type { AuditFilter } from '../../api/audit';
+import type { AuditFilter, AuditFilterOptions } from '../../api/audit';
 import type { AuditRecord } from '../../api/ops';
-import { cleanupAuditLogs, exportAuditLogs, listAuditRecords } from 
'../../services/opsService';
+import {
+  cleanupAuditLogs,
+  exportAuditLogs,
+  getAuditFilterOptions,
+  listAuditRecords,
+} from '../../services/opsService';
 import { downloadBlob } from '../../utils/download';
 
-const operationTypeColors: Record<string, string> = {
-  创建Topic: 'blue',
-  删除Topic: 'red',
-  修改配置: 'orange',
-  重置位点: 'purple',
-  ACL变更: 'cyan',
-  重启Broker: 'gold',
-  删除消费组: 'red',
+const emptyFilterOptions: AuditFilterOptions = {
+  operationTypes: [],
+  resourceTypes: [],
+  clusterIds: [],
+  results: [],
 };
 
-const operationTypeOptions = [
-  '创建Topic',
-  '删除Topic',
-  '修改配置',
-  '重置位点',
-  'ACL变更',
-  '重启Broker',
-  '删除消费组',
-];
+const formatFilterLabel = (value: string) => value.trim().replace(/_/g, ' ');
+
+const resultColor = (result: string) => {
+  const normalized = result.toUpperCase();
+  if (normalized === 'SUCCESS') return 'green';
+  if (normalized === 'PARTIAL') return 'orange';
+  return 'red';
+};
 
 const buildAuditFilter = (
   searchText: string,
   selectedType: string | undefined,
+  selectedResourceType: string | undefined,
+  selectedClusterId: string | undefined,
   dateRange: [Dayjs | null, Dayjs | null] | null,
   resultFilter: string,
 ): AuditFilter => ({
   search: searchText || undefined,
   operationType: selectedType,
+  resourceType: selectedResourceType,
+  clusterId: selectedClusterId,
   startDate: dateRange?.[0]?.format('YYYY-MM-DD'),
   endDate: dateRange?.[1]?.format('YYYY-MM-DD'),
   result: resultFilter === 'all' ? undefined : resultFilter,
@@ -85,19 +90,45 @@ const AuditPage: React.FC = () => {
   const [refreshKey, setRefreshKey] = useState(0);
   const [searchText, setSearchText] = useState('');
   const [selectedType, setSelectedType] = useState<string | 
undefined>(undefined);
+  const [selectedResourceType, setSelectedResourceType] = useState<string | 
undefined>(undefined);
+  const [selectedClusterId, setSelectedClusterId] = useState<string | 
undefined>(undefined);
   const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | 
null>(null);
   const [resultFilter, setResultFilter] = useState('all');
+  const [filterOptions, setFilterOptions] = 
useState<AuditFilterOptions>(emptyFilterOptions);
   const [cleanupModalOpen, setCleanupModalOpen] = useState(false);
   const [cleanupDays, setCleanupDays] = useState(30);
   const [exporting, setExporting] = useState(false);
 
+  useEffect(() => {
+    let cancelled = false;
+
+    void getAuditFilterOptions()
+      .then((options) => {
+        if (!cancelled) setFilterOptions(options);
+      })
+      .catch(() => {
+        if (!cancelled) setFilterOptions(emptyFilterOptions);
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [refreshKey]);
+
   useEffect(() => {
     let cancelled = false;
 
     void listAuditRecords({
       page,
       pageSize,
-      ...buildAuditFilter(searchText, selectedType, dateRange, resultFilter),
+      ...buildAuditFilter(
+        searchText,
+        selectedType,
+        selectedResourceType,
+        selectedClusterId,
+        dateRange,
+        resultFilter,
+      ),
     })
       .then((result) => {
         if (cancelled) return;
@@ -114,7 +145,17 @@ const AuditPage: React.FC = () => {
     return () => {
       cancelled = true;
     };
-  }, [page, pageSize, searchText, selectedType, dateRange, resultFilter, 
refreshKey]);
+  }, [
+    page,
+    pageSize,
+    searchText,
+    selectedType,
+    selectedResourceType,
+    selectedClusterId,
+    dateRange,
+    resultFilter,
+    refreshKey,
+  ]);
 
   const { Text } = Typography;
 
@@ -134,7 +175,14 @@ const AuditPage: React.FC = () => {
     setExporting(true);
     try {
       const csv = await exportAuditLogs(
-        buildAuditFilter(searchText, selectedType, dateRange, resultFilter),
+        buildAuditFilter(
+          searchText,
+          selectedType,
+          selectedResourceType,
+          selectedClusterId,
+          dateRange,
+          resultFilter,
+        ),
       );
       const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
       downloadBlob(blob, 
`rocketmq-audit-logs-${dayjs().format('YYYY-MM-DD')}.csv`);
@@ -159,8 +207,8 @@ const AuditPage: React.FC = () => {
     {
       title: t('audit.opType'),
       dataIndex: 'operationType',
-      width: 120,
-      render: (type: string) => <Tag color={operationTypeColors[type] || 
'default'}>{type}</Tag>,
+      width: 190,
+      render: (type: string) => <Tag>{formatFilterLabel(type)}</Tag>,
     },
     {
       title: t('audit.resourceType'),
@@ -189,12 +237,9 @@ const AuditPage: React.FC = () => {
       title: t('audit.result'),
       dataIndex: 'result',
       width: 80,
-      render: (result: string) =>
-        result.toUpperCase() === 'SUCCESS' ? (
-          <Tag color="green">{t('common.success')}</Tag>
-        ) : (
-          <Tag color="red">{t('common.failure')}</Tag>
-        ),
+      render: (result: string) => (
+        <Tag color={resultColor(result)}>{formatFilterLabel(result)}</Tag>
+      ),
     },
     {
       title: t('audit.error'),
@@ -209,8 +254,8 @@ const AuditPage: React.FC = () => {
       <PageHeader title={t('audit.title')} subtitle={t('audit.subtitle')} />
 
       {/* ─── Filter Bar ─── */}
-      <Flex justify="space-between" align="center" style={{ marginBottom: 16 
}}>
-        <Flex gap={16} align="center">
+      <Flex justify="space-between" align="center" gap={12} wrap style={{ 
marginBottom: 16 }}>
+        <Flex gap={12} align="center" wrap>
           <Input.Search
             placeholder={t('audit.searchPlaceholder')}
             value={searchText}
@@ -222,6 +267,7 @@ const AuditPage: React.FC = () => {
             allowClear
           />
           <Select
+            aria-label={t('audit.opType')}
             placeholder={t('audit.opType')}
             allowClear
             style={{ width: 180 }}
@@ -230,7 +276,37 @@ const AuditPage: React.FC = () => {
               setPage(1);
               setSelectedType(value);
             }}
-            options={operationTypeOptions.map((opt) => ({ label: opt, value: 
opt }))}
+            options={filterOptions.operationTypes.map((value) => ({
+              label: formatFilterLabel(value),
+              value,
+            }))}
+          />
+          <Select
+            aria-label={t('audit.resourceType')}
+            placeholder={t('audit.resourceType')}
+            allowClear
+            style={{ width: 150 }}
+            value={selectedResourceType}
+            onChange={(value) => {
+              setPage(1);
+              setSelectedResourceType(value);
+            }}
+            options={filterOptions.resourceTypes.map((value) => ({
+              label: formatFilterLabel(value),
+              value,
+            }))}
+          />
+          <Select
+            aria-label={t('audit.cluster')}
+            placeholder={t('audit.cluster')}
+            allowClear
+            style={{ width: 150 }}
+            value={selectedClusterId}
+            onChange={(value) => {
+              setPage(1);
+              setSelectedClusterId(value);
+            }}
+            options={filterOptions.clusterIds.map((value) => ({ label: value, 
value }))}
           />
           <DatePicker.RangePicker
             value={dateRange as [Dayjs | null, Dayjs | null] | null}
@@ -240,6 +316,7 @@ const AuditPage: React.FC = () => {
             }}
           />
           <Select
+            aria-label={t('audit.result')}
             value={resultFilter}
             onChange={(value) => {
               setPage(1);
@@ -248,8 +325,10 @@ const AuditPage: React.FC = () => {
             style={{ width: 120 }}
             options={[
               { label: t('common.all'), value: 'all' },
-              { label: t('common.success'), value: 'SUCCESS' },
-              { label: t('common.failure'), value: 'FAILURE' },
+              ...filterOptions.results.map((value) => ({
+                label: formatFilterLabel(value),
+                value,
+              })),
             ]}
           />
         </Flex>
@@ -268,13 +347,14 @@ const AuditPage: React.FC = () => {
       </Flex>
 
       {/* ─── Table ─── */}
-      <Card bodyStyle={{ padding: 0 }}>
+      <Card styles={{ body: { padding: 0 } }}>
         <Table
           size="small"
           columns={columns}
           dataSource={records}
           rowKey="id"
           loading={loading}
+          scroll={{ x: 1470 }}
           pagination={{
             current: page,
             pageSize,
diff --git a/web/src/services/opsService.test.ts 
b/web/src/services/opsService.test.ts
index 8ed1d707..aee8122f 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -21,6 +21,7 @@ import { mockAuditRecords } from '../mock/audit';
 import {
   createAlertRule,
   exportAuditLogs,
+  getAuditFilterOptions,
   listAlertRules,
   listAuditRecords,
   listSystemAlerts,
@@ -119,6 +120,17 @@ describe('ops service mock data', () => {
     expect(second.items[0]).not.toBe(first.items[0]);
   });
 
+  it('derives complete filter options from the default audit records', async 
() => {
+    const options = await getAuditFilterOptions();
+
+    expect(options.operationTypes).toEqual(
+      expect.arrayContaining(['CREATE_TOPIC', 'DELETE_GROUP', 'RESET_OFFSET']),
+    );
+    expect(options.resourceTypes).toEqual(expect.arrayContaining(['CLUSTER', 
'GROUP', 'TOPIC']));
+    expect(options.clusterIds).toEqual(expect.arrayContaining(['prod-cn', 
'prod-sh']));
+    expect(options.results).toEqual(expect.arrayContaining(['FAILED', 
'SUCCESS']));
+  });
+
   it('searches records safely when optional text fields are missing', async () 
=> {
     const record = {
       id: 'audit-null-safe',
@@ -140,6 +152,41 @@ describe('ops service mock data', () => {
     expect(result.items.map((item) => item.id)).toContain('audit-null-safe');
   });
 
+  it('derives filter options and applies resource and cluster filters', async 
() => {
+    const matching = {
+      id: 'audit-filter-match',
+      timestamp: '2026-08-01 10:00:00',
+      operator: 'admin',
+      operationType: 'RESET_OFFSET',
+      resourceType: 'CONSUMER_GROUP',
+      target: 'consumer-a',
+      clusterId: 'prod-filter',
+      detail: 'reset offset',
+      result: 'PARTIAL',
+      errorMessage: '',
+    } as AuditRecord;
+    const otherCluster = {
+      ...matching,
+      id: 'audit-filter-other-cluster',
+      clusterId: 'prod-other',
+    };
+    insertedRecords.push(matching, otherCluster);
+    auditRecords.push(matching, otherCluster);
+
+    const options = await getAuditFilterOptions();
+    const result = await listAuditRecords({
+      resourceType: 'CONSUMER_GROUP',
+      clusterId: 'prod-filter',
+      pageSize: 100,
+    });
+
+    expect(options.operationTypes).toContain('RESET_OFFSET');
+    expect(options.resourceTypes).toContain('CONSUMER_GROUP');
+    expect(options.clusterIds).toEqual(expect.arrayContaining(['prod-filter', 
'prod-other']));
+    expect(options.results).toContain('PARTIAL');
+    expect(result.items.map((record) => 
record.id)).toEqual(['audit-filter-match']);
+  });
+
   it('exports filtered audit records as escaped CSV', async () => {
     const record = {
       id: 'audit-csv-export',
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 5cd36c08..bdd9ad6d 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -1,5 +1,5 @@
-import { exportAuditLogs as exportAuditLogsApi } from '../api/audit';
-import type { AuditFilter } from '../api/audit';
+import { exportAuditLogs as exportAuditLogsApi, fetchAuditFilterOptions } from 
'../api/audit';
+import type { AuditFilter, AuditFilterOptions } from '../api/audit';
 import { isMockMode } from './dataMode';
 import * as opsApi from '../api/ops';
 import type { AlertRule, SystemAlert, AuditQuery, AuditRecord, PageResult } 
from '../api/ops';
@@ -29,6 +29,19 @@ function includesIgnoreCase(value: string | null | 
undefined, search: string): b
   return (value ?? '').toLowerCase().includes(search);
 }
 
+function distinctSorted(values: Array<string | null | undefined>): string[] {
+  return [...new Set(values.filter((value) => value?.trim()) as 
string[])].sort();
+}
+
+function getMockAuditFilterOptions(): AuditFilterOptions {
+  return {
+    operationTypes: distinctSorted(auditRecordsState.map((record) => 
record.operationType)),
+    resourceTypes: distinctSorted(auditRecordsState.map((record) => 
record.resourceType)),
+    clusterIds: distinctSorted(auditRecordsState.map((record) => 
record.clusterId)),
+    results: distinctSorted(auditRecordsState.map((record) => record.result)),
+  };
+}
+
 function filterAuditRecords(params: AuditFilter): AuditRecord[] {
   return auditRecordsState.filter((record) => {
     const search = params.search?.trim().toLowerCase();
@@ -41,6 +54,8 @@ function filterAuditRecords(params: AuditFilter): 
AuditRecord[] {
       return false;
     }
     if (params.operationType && record.operationType !== params.operationType) 
return false;
+    if (params.resourceType && record.resourceType !== params.resourceType) 
return false;
+    if (params.clusterId && record.clusterId !== params.clusterId) return 
false;
     if (params.startDate && record.timestamp < params.startDate) return false;
     if (params.endDate && record.timestamp > `${params.endDate} 23:59:59`) 
return false;
     return !params.result || record.result.toUpperCase() === 
params.result.toUpperCase();
@@ -171,6 +186,11 @@ export async function listAuditRecords(params: AuditQuery 
= {}): Promise<PageRes
   };
 }
 
+export async function getAuditFilterOptions(): Promise<AuditFilterOptions> {
+  if (!isMockMode()) return fetchAuditFilterOptions();
+  return getMockAuditFilterOptions();
+}
+
 export async function exportAuditLogs(params: AuditFilter = {}): 
Promise<string> {
   if (!isMockMode()) return exportAuditLogsApi(params);
   return formatAuditCsv(filterAuditRecords(params));

Reply via email to