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
commit 0e04932d96cef724a1ed6e9a5290e079cc2ae409 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));
