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