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 4f39ed608 feat(audit): add resource operation timelines (#4191)
4f39ed608 is described below
commit 4f39ed60834cf590a4120d9e3e479df83fa12892
Author: aias00 <[email protected]>
AuthorDate: Tue Sep 15 19:57:32 2026 +0800
feat(audit): add resource operation timelines (#4191)
(cherry picked from commit 96b0ee9319124740a9070d11028b24cb8f1bd2eb)
Signed-off-by: liuhy <[email protected]>
---
docs/api-spec.md | 15 +-
.../tool/handler/audit/AuditListToolHandler.java | 2 +-
.../rocketmq/studio/ops/audit/AuditController.java | 8 +-
.../rocketmq/studio/ops/audit/AuditRepository.java | 3 +-
.../rocketmq/studio/ops/audit/AuditService.java | 16 +-
.../ops/audit/MybatisPlusAuditRepository.java | 32 +--
.../handler/audit/AuditListToolHandlerTest.java | 4 +-
.../studio/ops/audit/AuditControllerTest.java | 37 +++-
.../studio/ops/audit/AuditServiceTest.java | 44 ++--
.../ops/audit/MybatisPlusAuditRepositoryTest.java | 52 ++++-
web/src/api/audit.test.ts | 51 ++++-
web/src/api/audit.ts | 3 +-
web/src/api/ops.ts | 6 +-
web/src/i18n/translations.ts | 17 ++
.../pages/ops/ResourceOperationTimelineDrawer.tsx | 236 ++++++++++++++++++++
web/src/pages/ops/__tests__/AuditPage.test.tsx | 44 ++++
.../ResourceOperationTimelineDrawer.test.tsx | 244 +++++++++++++++++++++
web/src/pages/ops/audit.tsx | 41 +++-
web/src/services/opsService.test.ts | 38 +++-
web/src/services/opsService.ts | 15 +-
20 files changed, 821 insertions(+), 87 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 854c38258..b9654abd2 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1794,7 +1794,7 @@ POST /api/system-alerts/clear-acknowledged
### 13.1 获取审计日志列表
```
-GET
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationType={type}&resourceType={resourceType}&clusterId={clusterId}&startDate={start}&endDate={end}&result={result}
+GET
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationType={type}&resourceType={resourceType}&target={target}&clusterId={clusterId}&clusterIdMissing={missing}&startDate={start}&endDate={end}&result={result}
```
**Query Parameters:**
@@ -1803,16 +1803,21 @@ GET
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationTyp
|------|------|------|------|
| `page` | `number` | 否 | 页码,默认 1 |
| `pageSize` | `number` | 否 | 每页条数,默认 20 |
-| `search` | `string` | 否 | 搜索(匹配 operator / target) |
+| `search` | `string` | 否 | 模糊搜索(匹配 operator / target / detail) |
| `operationType` | `string` | 否 | 操作类型过滤 |
| `resourceType` | `string` | 否 | 资源类型过滤 |
+| `target` | `string` | 否 | 操作对象精确过滤,使用等值匹配而非模糊搜索 |
| `clusterId` | `string` | 否 | 集群 ID 过滤 |
+| `clusterIdMissing` | `boolean` | 否 | 默认 `false`;为 `true` 时只返回集群 ID 为 null
或空字符串的记录,并忽略 `clusterId` |
| `startDate` | `string` | 否 | 开始日期 (YYYY-MM-DD) |
| `endDate` | `string` | 否 | 结束日期 (YYYY-MM-DD) |
| `result` | `string` | 否 | 结果过滤,传入筛选项接口返回的原始值 |
`startDate` 或 `endDate` 格式错误,以及 `startDate` 晚于 `endDate` 时,接口返回 HTTP 400。
+资源操作时间线使用 `resourceType + target + clusterId` 作为资源身份。对于没有集群范围的记录,
+省略 `clusterId` 并传入 `clusterIdMissing=true`。
+
**Response `data`:**
| 字段 | 类型 | 说明 |
@@ -1831,8 +1836,8 @@ GET
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationTyp
| `operator` | `string` | 操作人(如 `admin`, `ops-zhang`, `system`) |
| `operationType` | `string` | 持久化的操作类型代码,如 `CREATE_TOPIC` / `RESET_OFFSET` |
| `resourceType` | `string` | 资源类型代码,如 `TOPIC` / `GROUP` / `CLUSTER` |
-| `target` | `string` | 操作对象 |
-| `clusterId` | `string` | 所属集群 ID,无集群上下文时为 `null` |
+| `target` | `string \| null` | 操作对象,无操作对象时为 `null` |
+| `clusterId` | `string \| null` | 所属集群 ID,无集群上下文时为 `null` |
| `detail` | `string` | 详细描述 |
| `result` | `string` | 持久化的结果代码,如 `SUCCESS` / `FAILED` / `FAILURE` /
`PARTIAL` |
| `errorMessage` | `string` | 失败或部分成功时的错误信息 |
@@ -1857,7 +1862,7 @@ GET /api/audit-logs/filter-options
### 13.3 导出审计日志
```
-GET
/api/audit-logs/export?search={search}&operationType={type}&resourceType={resourceType}&clusterId={clusterId}&startDate={start}&endDate={end}&result={result}
+GET
/api/audit-logs/export?search={search}&operationType={type}&resourceType={resourceType}&target={target}&clusterId={clusterId}&clusterIdMissing={missing}&startDate={start}&endDate={end}&result={result}
```
查询参数与列表接口相同,但不包含 `page` 和 `pageSize`。接口返回全部匹配记录,不受当前表格分页影响。
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandler.java
index 8ecfa6212..71628541d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandler.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandler.java
@@ -53,7 +53,7 @@ public class AuditListToolHandler implements
ToolHandler<AuditListInput, PageOut
PageResult<AuditRecordVO> result = auditService.queryLogs(
input.page().page(), input.page().pageSize(),
input.search(), input.operationType(), input.resourceType(),
- null, input.startDate(), input.endDate(), input.result());
+ null, null, false, input.startDate(), input.endDate(),
input.result());
return PageOutput.from(result, AuditItem::from);
}
}
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 0213808f1..570015e61 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
@@ -43,12 +43,14 @@ public class AuditController {
@RequestParam(required = false) String search,
@RequestParam(required = false) String operationType,
@RequestParam(required = false) String resourceType,
+ @RequestParam(required = false) String target,
@RequestParam(required = false) String clusterId,
+ @RequestParam(defaultValue = "false") boolean clusterIdMissing,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String result) {
return Result.ok(auditService.queryLogs(page, pageSize, search,
operationType,
- resourceType, clusterId, startDate, endDate, result));
+ resourceType, target, clusterId, clusterIdMissing, startDate,
endDate, result));
}
@GetMapping("/filter-options")
@@ -74,12 +76,14 @@ public class AuditController {
@RequestParam(required = false) String search,
@RequestParam(required = false) String operationType,
@RequestParam(required = false) String resourceType,
+ @RequestParam(required = false) String target,
@RequestParam(required = false) String clusterId,
+ @RequestParam(defaultValue = "false") boolean clusterIdMissing,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String result) {
return Result.ok(auditService.exportLogs(search, operationType,
resourceType,
- clusterId, startDate, endDate, result));
+ target, clusterId, clusterIdMissing, startDate, endDate,
result));
}
@PostMapping("/cleanup")
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 e3f2754a2..96a6773a3 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
@@ -21,7 +21,8 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
public interface AuditRepository {
PageResult<AuditRecordVO> findPage(String search, String operationType,
- String resourceType, String clusterId,
+ String resourceType, String target,
String clusterId,
+ boolean clusterIdMissing,
LocalDateTime startDate, LocalDateTime
endDate,
String result, int page, int pageSize);
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 734b6839c..a514d4fe8 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
@@ -49,13 +49,14 @@ public class AuditService {
public PageResult<AuditRecordVO> queryLogs(int page, int pageSize, String
search,
String operationType, String
resourceType,
- String clusterId, String
startDate,
+ String target, String clusterId,
boolean clusterIdMissing,
+ 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, resourceType, clusterId,
+ return findPage(search, operationType, resourceType, target,
clusterId, clusterIdMissing,
startDate, endDate, result, page, pageSize);
}
@@ -71,9 +72,10 @@ public class AuditService {
}
public String exportLogs(String search, String operationType, String
resourceType,
- String clusterId, String startDate, String
endDate, String result) {
+ String target, String clusterId, boolean
clusterIdMissing, String startDate,
+ String endDate, String result) {
PageResult<AuditRecordVO> page = findPage(
- search, operationType, resourceType, clusterId,
+ search, operationType, resourceType, target, clusterId,
clusterIdMissing,
startDate, endDate, result, 1, MAX_EXPORT_RECORDS);
if (page.getTotal() > MAX_EXPORT_RECORDS) {
throw new BusinessException(400,
@@ -158,11 +160,13 @@ public class AuditService {
}
private PageResult<AuditRecordVO> findPage(String search, String
operationType,
- String resourceType, String
clusterId,
+ String resourceType, String
target, String clusterId,
+ boolean clusterIdMissing,
String startDate, String
endDate,
String result, int page, int
pageSize) {
DateRange range = parseDateRange(startDate, endDate);
- return auditRepository.findPage(search, operationType, resourceType,
clusterId,
+ return auditRepository.findPage(search, operationType, resourceType,
target, clusterId,
+ clusterIdMissing,
range.start(), range.end(), result, page, pageSize);
}
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 88de212fe..8caf98497 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
@@ -54,21 +54,14 @@ public class MybatisPlusAuditRepository implements
AuditRepository {
@Override
public PageResult<AuditRecordVO> findPage(String search, String
operationType,
- String resourceType, String
clusterId,
+ String resourceType, String
target, String clusterId,
+ boolean clusterIdMissing,
LocalDateTime startDate,
LocalDateTime endDate,
String result, int page, int
pageSize) {
- QueryWrapper<RmqOperationAudit> query = new
QueryWrapper<RmqOperationAudit>()
- .and(StringUtils.hasText(search), w -> w
- .like("operator", search)
- .or().like("resource_name", search)
- .or().like("detail", search))
- .eq(StringUtils.hasText(operationType), "operation",
operationType)
- .eq(StringUtils.hasText(resourceType), "resource_type",
resourceType)
- .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId)
- .ge(startDate != null, "gmt_create", startDate)
- .le(endDate != null, "gmt_create", endDate)
- .eq(StringUtils.hasText(result), "result", result)
- .orderByDesc("gmt_create", "id");
+ QueryWrapper<RmqOperationAudit> query = new QueryWrapper<>();
+ applyFilters(query, search, operationType, resourceType, target,
clusterId, clusterIdMissing,
+ startDate, endDate, result);
+ query.orderByDesc("gmt_create", "id");
Page<RmqOperationAudit> resultPage = auditMapper.selectPage(
new Page<>(page, pageSize), query);
List<AuditRecordVO> records = resultPage.getRecords().stream()
@@ -113,7 +106,7 @@ public class MybatisPlusAuditRepository implements
AuditRepository {
String clusterId, LocalDateTime startDate,
LocalDateTime endDate,
String result) {
Consumer<QueryWrapper<RmqOperationAudit>> filters = query ->
applyFilters(query, search,
- operationType, resourceType, clusterId, startDate, endDate,
result);
+ operationType, resourceType, null, clusterId, false,
startDate, endDate, result);
// One GROUP BY result query computes total / SUCCESS / FAILED /
PARTIAL in a single
// round trip instead of four separate COUNT(*) statements. Note that
when the caller
@@ -194,8 +187,13 @@ public class MybatisPlusAuditRepository implements
AuditRepository {
.toList();
}
+ /**
+ * Single source of truth for the audit filters, shared by the paged list,
the summary and the
+ * hotspot queries so a card can never disagree with the rows it describes.
+ */
private void applyFilters(QueryWrapper<RmqOperationAudit> query, String
search,
- String operationType, String resourceType,
String clusterId,
+ String operationType, String resourceType,
String target,
+ String clusterId, boolean clusterIdMissing,
LocalDateTime startDate, LocalDateTime endDate,
String result) {
query.and(StringUtils.hasText(search), w -> w
.like("operator", search)
@@ -203,7 +201,9 @@ public class MybatisPlusAuditRepository implements
AuditRepository {
.or().like("detail", search))
.eq(StringUtils.hasText(operationType), "operation",
operationType)
.eq(StringUtils.hasText(resourceType), "resource_type",
resourceType)
- .eq(StringUtils.hasText(clusterId), "cluster_id", clusterId)
+ .eq(StringUtils.hasText(target), "resource_name", target)
+ .eq(!clusterIdMissing && StringUtils.hasText(clusterId),
"cluster_id", clusterId)
+ .and(clusterIdMissing, scope ->
scope.isNull("cluster_id").or().eq("cluster_id", ""))
.ge(startDate != null, "gmt_create", startDate)
.le(endDate != null, "gmt_create", endDate)
.eq(StringUtils.hasText(result), "result", result);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandlerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandlerTest.java
index 5cbfd6502..ef6b94051 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandlerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/audit/AuditListToolHandlerTest.java
@@ -62,7 +62,7 @@ class AuditListToolHandlerTest {
.build();
record.setId(42L);
when(auditService.queryLogs(eq(1), eq(20), eq("topic"), eq("DELETE"),
eq("TOPIC"),
- isNull(), eq("2026-09-01"), eq("2026-09-14"), eq("SUCCESS")))
+ isNull(), isNull(), eq(false), eq("2026-09-01"),
eq("2026-09-14"), eq("SUCCESS")))
.thenReturn(PageResult.of(List.of(record), 1, 1, 20));
PageOutput<AuditItem> result = handler.execute(new AuditListInput(
@@ -77,6 +77,6 @@ class AuditListToolHandlerTest {
// Rows written by another Instance stay visible: cluster_id carries
the attribution.
assertThat(item.clusterId()).isEqualTo("instance-b");
verify(auditService).queryLogs(eq(1), eq(20), eq("topic"),
eq("DELETE"), eq("TOPIC"),
- isNull(), eq("2026-09-01"), eq("2026-09-14"), eq("SUCCESS"));
+ isNull(), isNull(), eq(false), eq("2026-09-01"),
eq("2026-09-14"), eq("SUCCESS"));
}
}
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 7a0294b36..c707013c1 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
@@ -68,7 +68,8 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
.result("SUCCESS")
.build();
when(auditService.queryLogs(eq(2), eq(10), eq("topic"), eq("DELETE"),
- eq("TOPIC"), eq("prod-cn"), eq("2026-07-01"),
eq("2026-07-24"), eq("SUCCESS")))
+ eq("TOPIC"), eq("topic-a"), eq("prod-cn"), eq(false),
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")
@@ -77,6 +78,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
.param("search", "topic")
.param("operationType", "DELETE")
.param("resourceType", "TOPIC")
+ .param("target", "topic-a")
.param("clusterId", "prod-cn")
.param("startDate", "2026-07-01")
.param("endDate", "2026-07-24")
@@ -88,7 +90,8 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
.andExpect(jsonPath("$.data.total").value(1));
verify(auditService).queryLogs(eq(2), eq(10), eq("topic"),
eq("DELETE"),
- eq("TOPIC"), eq("prod-cn"), eq("2026-07-01"),
eq("2026-07-24"), eq("SUCCESS"));
+ eq("TOPIC"), eq("topic-a"), eq("prod-cn"), eq(false),
eq("2026-07-01"),
+ eq("2026-07-24"), eq("SUCCESS"));
}
@Test
@@ -168,7 +171,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
@Test
void queryLogsShouldUseDefaultPagination() throws Exception {
when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(),
isNull(), isNull(),
- isNull(), isNull(), isNull()))
+ isNull(), eq(false), isNull(), isNull(), isNull()))
.thenReturn(PageResult.of(List.of(), 0, 1, 20));
mockMvc.perform(get("/api/audit-logs"))
@@ -178,19 +181,37 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
.andExpect(jsonPath("$.data.size").value(20));
verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(),
isNull(), isNull(),
- isNull(), isNull(), isNull());
+ isNull(), eq(false), isNull(), isNull(), isNull());
+ }
+
+ @Test
+ void queryLogsShouldForwardAnExplicitMissingClusterScopeTest() throws
Exception {
+ when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(),
eq("SETTINGS"),
+ eq("general"), isNull(), eq(true), isNull(), isNull(),
isNull()))
+ .thenReturn(PageResult.of(List.of(), 0, 1, 20));
+
+ mockMvc.perform(get("/api/audit-logs")
+ .param("resourceType", "SETTINGS")
+ .param("target", "general")
+ .param("clusterIdMissing", "true"))
+ .andExpect(status().isOk());
+
+ verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(),
eq("SETTINGS"),
+ eq("general"), isNull(), eq(true), 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("TOPIC"),
eq("prod-cn"),
- eq("2026-08-01"), eq("2026-08-02"),
eq("SUCCESS"))).thenReturn(csv);
+ when(auditService.exportLogs(eq("topic"), eq("DELETE"), eq("TOPIC"),
eq("topic-a"),
+ eq("prod-cn"), eq(false), 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("target", "topic-a")
.param("clusterId", "prod-cn")
.param("startDate", "2026-08-01")
.param("endDate", "2026-08-02")
@@ -199,8 +220,8 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(csv));
- verify(auditService).exportLogs(eq("topic"), eq("DELETE"),
eq("TOPIC"), eq("prod-cn"),
- eq("2026-08-01"), eq("2026-08-02"), eq("SUCCESS"));
+ verify(auditService).exportLogs(eq("topic"), eq("DELETE"),
eq("TOPIC"), eq("topic-a"),
+ eq("prod-cn"), eq(false), eq("2026-08-01"), eq("2026-08-02"),
eq("SUCCESS"));
}
@Test
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 eaa5972d1..cfbeaaa59 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
@@ -89,32 +89,34 @@ class AuditServiceTest {
@Test
void queryLogsDelegatesPaginationAndFiltersToRepository() {
AuditRecordVO record =
AuditRecordVO.builder().operationType("CREATE").build();
- when(auditRepository.findPage(eq("topic-a"), eq("CREATE"),
eq("TOPIC"), eq("prod-cn"),
- isNull(), isNull(), eq("SUCCESS"), eq(2), eq(20)))
+ when(auditRepository.findPage(eq("topic-a"), eq("CREATE"),
eq("TOPIC"), eq("orders"),
+ eq("prod-cn"), eq(false), 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", "TOPIC", "prod-cn", null, null,
"SUCCESS");
+ 2, 20, "topic-a", "CREATE", "TOPIC", "orders", "prod-cn",
false,
+ null, null, "SUCCESS");
assertThat(result.getItems()).containsExactly(record);
assertThat(result.getTotal()).isEqualTo(21);
- verify(auditRepository).findPage(eq("topic-a"), eq("CREATE"),
eq("TOPIC"), eq("prod-cn"),
- isNull(), isNull(), eq("SUCCESS"), eq(2), eq(20));
+ verify(auditRepository).findPage(eq("topic-a"), eq("CREATE"),
eq("TOPIC"), eq("orders"),
+ eq("prod-cn"), eq(false), isNull(), isNull(), eq("SUCCESS"),
eq(2), eq(20));
}
@Test
void queryLogsParsesDateRangeBeforeDelegating() {
when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
- any(LocalDateTime.class), any(LocalDateTime.class), isNull(),
eq(1), eq(10)))
+ isNull(), eq(false), any(LocalDateTime.class),
any(LocalDateTime.class),
+ isNull(), eq(1), eq(10)))
.thenReturn(PageResult.empty(1, 10));
- auditService.queryLogs(1, 10, null, null, null, null,
+ auditService.queryLogs(1, 10, null, null, null, null, null, false,
"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(), isNull(),
isNull(),
- start.capture(), end.capture(), isNull(), eq(1), eq(10));
+ isNull(), eq(false), 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));
}
@@ -122,11 +124,11 @@ class AuditServiceTest {
@Test
void queryLogsRejectsInvalidPageBounds() {
assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null,
null, null,
- null, null, null))
+ null, false, null, null, null))
.isInstanceOf(BusinessException.class)
.hasMessage("page must be greater than 0");
assertThatThrownBy(() -> auditService.queryLogs(1, 101, null, null,
null, null,
- null, null, null))
+ null, false, null, null, null))
.isInstanceOf(BusinessException.class)
.hasMessage("pageSize must be between 1 and 100");
}
@@ -134,7 +136,7 @@ class AuditServiceTest {
@Test
void queryLogsRejectsInvalidDateRange() {
assertThatThrownBy(() -> auditService.queryLogs(1, 10, null, null,
null, null,
- "2026-08-02", "2026-08-01", null))
+ null, false, "2026-08-02", "2026-08-01", null))
.isInstanceOf(BusinessException.class)
.hasMessage("startDate must not be after endDate");
}
@@ -152,11 +154,12 @@ class AuditServiceTest {
.result("FAILED")
.errorMessage("=denied")
.build();
- 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)))
+ when(auditRepository.findPage(eq("topic"), eq("DELETE"), eq("TOPIC"),
eq("topic,a"),
+ eq("prod-cn"), eq(false), 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", "TOPIC",
"prod-cn",
+ String csv = auditService.exportLogs("topic", "DELETE", "TOPIC",
"topic,a", "prod-cn", false,
"2026-08-01", "2026-08-02", "FAILED");
assertThat(csv).contains("resourceType,target,clusterId,detail,result,errorMessage")
@@ -174,7 +177,7 @@ class AuditServiceTest {
.target("topic-a")
.result("SUCCESS")
.build();
- when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
+ when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
isNull(), eq(false),
any(LocalDateTime.class), any(LocalDateTime.class), isNull(),
eq(1), eq(10_000)))
.thenReturn(PageResult.of(List.of(record), 1, 1, 10_000));
@@ -183,12 +186,12 @@ class AuditServiceTest {
// The stored base is server-local and stays unconverted; only the
column name
// tells the consumer which zone the values are in.
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
- assertThat(auditService.exportLogs(null, null, null, null,
"2026-08-01", "2026-08-02", null))
+ assertThat(auditService.exportLogs(null, null, null, null, null,
false, "2026-08-01", "2026-08-02", null))
.startsWith("\uFEFFtimestamp(UTC+08:00),operator,")
.contains("\"2026-08-01T09:30\"");
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
- assertThat(auditService.exportLogs(null, null, null, null,
"2026-08-01", "2026-08-02", null))
+ assertThat(auditService.exportLogs(null, null, null, null, null,
false, "2026-08-01", "2026-08-02", null))
.startsWith("\uFEFFtimestamp(UTC),operator,");
} finally {
TimeZone.setDefault(originalZone);
@@ -197,11 +200,12 @@ class AuditServiceTest {
@Test
void exportLogsRejectsResultsBeyondBound() {
- when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
isNull(), isNull(),
- isNull(), eq(1), eq(10_000)))
+ when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
isNull(),
+ eq(false), 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, null, null))
+ assertThatThrownBy(() -> auditService.exportLogs(
+ null, null, null, null, null, false, null, null, null))
.isInstanceOf(BusinessException.class)
.hasMessage("Audit log export exceeds the maximum of 10000
records; narrow the filters");
}
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 ebe8f9dd0..41b0b880f 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
@@ -68,7 +68,8 @@ class MybatisPlusAuditRepositoryTest {
when(auditMapper.selectPage(any(IPage.class),
any(Wrapper.class))).thenReturn(mapperPage);
PageResult<AuditRecordVO> result = repository.findPage(
- "orders", "DELETE_TOPIC", "TOPIC", "prod-cn", null, null,
"FAILED", 2, 25);
+ "orders", "DELETE_TOPIC", "TOPIC", "orders", "prod-cn", false,
+ null, null, "FAILED", 2, 25);
ArgumentCaptor<IPage<RmqOperationAudit>> pageCaptor =
ArgumentCaptor.forClass(IPage.class);
ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
@@ -82,7 +83,54 @@ class MybatisPlusAuditRepositoryTest {
assertThat(record.getClusterId()).isEqualTo("prod-cn");
assertThat(record.getErrorMessage()).isEqualTo("denied");
assertThat(queryCaptor.getValue().getSqlSegment())
- .contains("operation", "resource_type", "cluster_id",
"result", "gmt_create", "id");
+ .contains("operation", "resource_type", "resource_name =",
"cluster_id",
+ "result", "gmt_create", "id");
+ }
+
+ @Test
+ void findPageUsesEqualityForExactTargetTest() {
+ when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
+ .thenReturn(new Page<RmqOperationAudit>(1,
20).setRecords(List.of()).setTotal(0));
+
+ repository.findPage(null, null, "TOPIC", "orders", "prod-cn", false,
+ null, null, null, 1, 20);
+
+ ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(auditMapper).selectPage(any(IPage.class),
queryCaptor.capture());
+ assertThat(queryCaptor.getValue().getSqlSegment())
+ .contains("resource_type =", "resource_name =", "cluster_id =")
+ .doesNotContain("resource_name LIKE");
+ }
+
+ @Test
+ void findPageCanRestrictTimelineToRecordsWithoutAClusterTest() {
+ when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
+ .thenReturn(new Page<RmqOperationAudit>(1,
20).setRecords(List.of()).setTotal(0));
+
+ repository.findPage(null, null, "SETTINGS", "general", null, true,
+ null, null, null, 1, 20);
+
+ ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(auditMapper).selectPage(any(IPage.class),
queryCaptor.capture());
+ assertThat(queryCaptor.getValue().getSqlSegment())
+ .contains("resource_name =", "cluster_id IS NULL", "OR
cluster_id =");
+ }
+
+ @Test
+ void findPageMissingClusterScopeOverridesClusterIdTest() {
+ when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
+ .thenReturn(new Page<RmqOperationAudit>(1,
20).setRecords(List.of()).setTotal(0));
+
+ repository.findPage(null, null, "SETTINGS", "general",
"ignored-cluster", true,
+ null, null, null, 1, 20);
+
+ ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(auditMapper).selectPage(any(IPage.class),
queryCaptor.capture());
+ assertThat(queryCaptor.getValue().getSqlSegment())
+ .contains("cluster_id IS NULL", "OR cluster_id =");
+ assertThat(((QueryWrapper<RmqOperationAudit>) queryCaptor.getValue())
+ .getParamNameValuePairs().values())
+ .doesNotContain("ignored-cluster");
}
@Test
diff --git a/web/src/api/audit.test.ts b/web/src/api/audit.test.ts
index fd80476f7..62b628b5d 100644
--- a/web/src/api/audit.test.ts
+++ b/web/src/api/audit.test.ts
@@ -42,16 +42,27 @@ describe('audit log API', () => {
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'
});
+ expect(config.params).toEqual({
+ page: 2,
+ pageSize: 10,
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ result: 'SUCCESS',
+ });
return [200, { code: 200, data: { items: [], total: 12, page: 2, size:
10 } }];
});
- await expect(listAuditRecords({ page: 2, pageSize: 10, result: 'SUCCESS'
})).resolves.toEqual({
- items: [],
- total: 12,
- page: 2,
- size: 10,
- });
+ await expect(
+ listAuditRecords({
+ page: 2,
+ pageSize: 10,
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ result: 'SUCCESS',
+ }),
+ ).resolves.toEqual({ items: [], total: 12, page: 2, size: 10 });
});
it('returns the backend cleanup count', async () => {
@@ -66,11 +77,23 @@ describe('audit log API', () => {
it('exports all records matching the supplied filters', async () => {
const csv = '\uFEFFtimestamp,operator\r\n"2026-08-01T09:30","admin"\r\n';
mock.onGet('/audit-logs/export').reply((config) => {
- expect(config.params).toEqual({ search: 'topic', result: 'SUCCESS' });
+ expect(config.params).toEqual({
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ result: 'SUCCESS',
+ });
return [200, { code: 200, data: csv }];
});
- await expect(exportAuditLogs({ search: 'topic', result: 'SUCCESS'
})).resolves.toBe(csv);
+ await expect(
+ exportAuditLogs({
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ result: 'SUCCESS',
+ }),
+ ).resolves.toBe(csv);
});
it('loads summary metrics with the supplied filters', async () => {
@@ -85,12 +108,18 @@ describe('audit log API', () => {
byResourceType: [{ name: 'TOPIC', count: 9 }],
};
mock.onGet('/audit-logs/summary').reply((config) => {
- expect(config.params).toEqual({ clusterId: 'prod-cn', startDate:
'2026-08-01' });
+ expect(config.params).toEqual({
+ clusterId: 'prod-cn',
+ startDate: '2026-08-01',
+ });
return [200, { code: 200, data: summary }];
});
await expect(
- fetchAuditSummary({ clusterId: 'prod-cn', startDate: '2026-08-01' }),
+ fetchAuditSummary({
+ clusterId: 'prod-cn',
+ startDate: '2026-08-01',
+ }),
).resolves.toEqual(summary);
});
});
diff --git a/web/src/api/audit.ts b/web/src/api/audit.ts
index 33945d3ca..43856c88c 100644
--- a/web/src/api/audit.ts
+++ b/web/src/api/audit.ts
@@ -19,6 +19,7 @@ import client from './client';
import type { AuditQuery } from './ops';
export type AuditFilter = Omit<AuditQuery, 'page' | 'pageSize'>;
+export type AuditSummaryFilter = Omit<AuditFilter, 'target' |
'clusterIdMissing'>;
export interface AuditFilterOptions {
operationTypes: string[];
@@ -53,7 +54,7 @@ export async function exportAuditLogs(params?: AuditFilter):
Promise<string> {
return res.data.data;
}
-export async function fetchAuditSummary(params?: AuditFilter):
Promise<AuditSummary> {
+export async function fetchAuditSummary(params?: AuditSummaryFilter):
Promise<AuditSummary> {
const res = await client.get<{ data: AuditSummary }>('/audit-logs/summary',
{ params });
return res.data.data;
}
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index cf05f8498..a8cd5eb73 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -175,8 +175,8 @@ export interface AuditRecord {
operator: string;
operationType: string;
resourceType: string;
- target: string;
- clusterId: string;
+ target: string | null;
+ clusterId: string | null;
detail: string;
result: string;
errorMessage: string;
@@ -195,7 +195,9 @@ export interface AuditQuery {
search?: string;
operationType?: string;
resourceType?: string;
+ target?: string;
clusterId?: string;
+ clusterIdMissing?: boolean;
startDate?: string;
endDate?: string;
result?: string;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b215b8913..c2e009927 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1217,6 +1217,23 @@ const translations: Record<string, Record<Lang, string>>
= {
'audit.cluster': { zh: '集群', en: 'Cluster' },
'audit.result': { zh: '结果', en: 'Result' },
'audit.error': { zh: '失败原因', en: 'Error' },
+ 'audit.timelineTitle': { zh: '资源操作时间线', en: 'Resource Operation Timeline' },
+ 'audit.timelineView': {
+ zh: '查看 {target} 操作时间线',
+ en: 'View operation timeline for {target}',
+ },
+ 'audit.timelineDateRange': { zh: '时间线日期范围', en: 'Timeline date range' },
+ 'audit.timelineNoCluster': { zh: '无集群范围', en: 'No cluster scope' },
+ 'audit.timelineExport': { zh: '导出时间线', en: 'Export Timeline' },
+ 'audit.timelineLoadFailed': {
+ zh: '资源操作时间线加载失败,请稍后重试',
+ en: 'Failed to load the resource operation timeline. Try again later.',
+ },
+ 'audit.timelineExportFailed': {
+ zh: '资源操作时间线导出失败,请稍后重试',
+ en: 'Failed to export the resource operation timeline. Try again later.',
+ },
+ 'audit.timelineEmpty': { zh: '该资源暂无操作记录', en: 'No operations found for this
resource' },
'audit.searchPlaceholder': { zh: '搜索操作人或操作对象', en: 'Search operator or
target' },
'audit.cleanup': { zh: '清理日志', en: 'Cleanup' },
'audit.cleanupTitle': { zh: '清理审计日志', en: 'Cleanup Audit Log' },
diff --git a/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
b/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
new file mode 100644
index 000000000..d2904b530
--- /dev/null
+++ b/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { DownloadOutlined } from '@ant-design/icons';
+import {
+ Alert,
+ Button,
+ DatePicker,
+ Drawer,
+ Empty,
+ Flex,
+ Pagination,
+ Space,
+ Spin,
+ Tag,
+ Timeline,
+ Typography,
+ message,
+} from 'antd';
+import type { Dayjs } from 'dayjs';
+import dayjs from 'dayjs';
+import type { AuditFilter } from '../../api/audit';
+import type { AuditRecord } from '../../api/ops';
+import { exportAuditLogs, listAuditRecords } from '../../services/opsService';
+import { downloadBlob } from '../../utils/download';
+import { formatDateTime } from '../../utils/format';
+import { useLang } from '../../i18n/LangContext';
+import {
+ getAuditOperationPresentation,
+ getAuditResourcePresentation,
+ getAuditResultPresentation,
+ parseAuditDetail,
+} from './auditPresentation';
+
+export interface AuditTimelineResource {
+ resourceType: string;
+ target: string;
+ clusterId?: string | null;
+}
+
+interface ResourceOperationTimelineDrawerProps {
+ open: boolean;
+ resource: AuditTimelineResource;
+ onClose: () => void;
+}
+
+const buildTimelineFilter = (
+ resource: AuditTimelineResource,
+ dateRange: [Dayjs | null, Dayjs | null] | null,
+): AuditFilter => ({
+ resourceType: resource.resourceType,
+ target: resource.target,
+ ...(resource.clusterId?.trim() ? { clusterId: resource.clusterId } : {
clusterIdMissing: true }),
+ ...(dateRange?.[0] ? { startDate: dateRange[0].format('YYYY-MM-DD') } : {}),
+ ...(dateRange?.[1] ? { endDate: dateRange[1].format('YYYY-MM-DD') } : {}),
+});
+
+const ResourceOperationTimelineDrawer = ({
+ open,
+ resource,
+ onClose,
+}: ResourceOperationTimelineDrawerProps) => {
+ const { t } = useLang();
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] |
null>(null);
+ const [response, setResponse] = useState<{
+ queryKey: string;
+ records: AuditRecord[];
+ total: number;
+ error: boolean;
+ } | null>(null);
+ const [exporting, setExporting] = useState(false);
+ const requestVersion = useRef(0);
+ const filter = useMemo(() => buildTimelineFilter(resource, dateRange),
[dateRange, resource]);
+ const queryKey = JSON.stringify({ page, pageSize, filter });
+ const currentResponse = response?.queryKey === queryKey ? response : null;
+ const loading = open && currentResponse === null;
+
+ useEffect(() => {
+ const version = ++requestVersion.current;
+ if (!open) return;
+ void listAuditRecords({ page, pageSize, ...filter })
+ .then((result) => {
+ if (requestVersion.current !== version) return;
+ setResponse({
+ queryKey,
+ records: result.items,
+ total: result.total,
+ error: false,
+ });
+ })
+ .catch(() => {
+ if (requestVersion.current !== version) return;
+ setResponse({ queryKey, records: [], total: 0, error: true });
+ });
+ return () => {
+ requestVersion.current += 1;
+ };
+ }, [filter, open, page, pageSize, queryKey]);
+
+ const handleExport = async () => {
+ setExporting(true);
+ try {
+ const csv = await exportAuditLogs(filter);
+ downloadBlob(
+ new Blob([csv], { type: 'text/csv;charset=utf-8' }),
+ `rocketmq-audit-resource-${dayjs().format('YYYY-MM-DD')}.csv`,
+ );
+ } catch {
+ message.error(t('audit.timelineExportFailed'));
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ const resourcePresentation =
getAuditResourcePresentation(resource.resourceType);
+ const resourceLabel = resourcePresentation.labelKey
+ ? t(resourcePresentation.labelKey)
+ : resourcePresentation.label;
+
+ return (
+ <Drawer
+ title={t('audit.timelineTitle')}
+ open={open}
+ width="min(720px, calc(100vw - 16px))"
+ onClose={onClose}
+ destroyOnHidden
+ extra={
+ <Button
+ icon={<DownloadOutlined />}
+ aria-label={t('audit.timelineExport')}
+ loading={exporting}
+ onClick={() => void handleExport()}
+ >
+ {t('audit.timelineExport')}
+ </Button>
+ }
+ >
+ <Space direction="vertical" size={20} style={{ width: '100%' }}>
+ <Flex gap={8} wrap="wrap" align="center">
+ <Tag color={resourcePresentation.color}>{resourceLabel}</Tag>
+ <Typography.Text strong>{resource.target}</Typography.Text>
+ <Typography.Text type="secondary">
+ {resource.clusterId?.trim() || t('audit.timelineNoCluster')}
+ </Typography.Text>
+ </Flex>
+
+ <DatePicker.RangePicker
+ aria-label={t('audit.timelineDateRange')}
+ style={{ width: '100%', maxWidth: 320 }}
+ value={dateRange}
+ onChange={(value) => {
+ setPage(1);
+ setDateRange(value as [Dayjs | null, Dayjs | null] | null);
+ }}
+ />
+
+ {loading ? (
+ <Flex justify="center" style={{ minHeight: 240, paddingTop: 80 }}>
+ <Spin />
+ </Flex>
+ ) : currentResponse?.error ? (
+ <Alert type="error" showIcon message={t('audit.timelineLoadFailed')}
/>
+ ) : currentResponse && currentResponse.records.length > 0 ? (
+ <>
+ <Timeline
+ items={currentResponse.records.map((record) => {
+ const operation =
getAuditOperationPresentation(record.operationType);
+ const result = getAuditResultPresentation(record.result);
+ const detail = parseAuditDetail(record.detail)
+ .map((token) => (token.label ? `${token.label}:
${token.value}` : token.value))
+ .join(', ');
+ return {
+ color: result.color,
+ children: (
+ <Space direction="vertical" size={4} style={{ width:
'100%' }}>
+ <Flex gap={8} align="center" wrap="wrap">
+ <Typography.Text strong>
+ {operation.labelKey ? t(operation.labelKey) :
operation.label}
+ </Typography.Text>
+ <Tag color={result.color}>
+ {result.labelKey ? t(result.labelKey) : result.label}
+ </Tag>
+ </Flex>
+ <Typography.Text type="secondary">
+ {formatDateTime(record.timestamp)} · {record.operator
|| '-'}
+ </Typography.Text>
+ {detail && <Typography.Text>{detail}</Typography.Text>}
+ {record.errorMessage && (
+ <Typography.Text
type="danger">{record.errorMessage}</Typography.Text>
+ )}
+ </Space>
+ ),
+ };
+ })}
+ />
+ <Flex justify="flex-end">
+ <Pagination
+ current={page}
+ pageSize={pageSize}
+ total={currentResponse.total}
+ showSizeChanger
+ responsive
+ pageSizeOptions={[10, 20, 50, 100]}
+ onChange={(nextPage, nextPageSize) => {
+ setPage(nextPageSize === pageSize ? nextPage : 1);
+ setPageSize(nextPageSize);
+ }}
+ />
+ </Flex>
+ </>
+ ) : (
+ <Empty description={t('audit.timelineEmpty')} />
+ )}
+ </Space>
+ </Drawer>
+ );
+};
+
+export default ResourceOperationTimelineDrawer;
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index 3d07f0fb3..ee0bea6fa 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -186,6 +186,50 @@ describe('Audit page', () => {
expect(screen.getByText('timestamp: 1784246400000')).toBeInTheDocument();
});
+ it('opens a resource timeline from a non-empty audit target', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AuditPage />);
+
+ await user.click(await screen.findByRole('button', { name: '查看 topic-a
操作时间线' }));
+
+ expect(await screen.findByText('资源操作时间线')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(opsService.listAuditRecords).toHaveBeenCalledWith({
+ page: 1,
+ pageSize: 20,
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ }),
+ );
+ });
+
+ it('does not offer a timeline action for an empty audit target', async () =>
{
+ vi.mocked(opsService.listAuditRecords).mockResolvedValueOnce({
+ items: [
+ {
+ id: 3,
+ timestamp: '2026-08-01 12:00:00',
+ operator: 'ops-user',
+ operationType: 'UPDATE_SETTINGS',
+ resourceType: 'SETTINGS',
+ target: '',
+ clusterId: null,
+ detail: 'updated settings',
+ result: 'SUCCESS',
+ errorMessage: '',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ renderWithProviders(<AuditPage />);
+
+ await waitFor(() =>
expect(opsService.listAuditRecords).toHaveBeenCalled());
+ expect(screen.queryByRole('button', { name: /操作时间线/
})).not.toBeInTheDocument();
+ });
+
it('loads a filtered server-side summary dashboard', async () => {
const user = userEvent.setup();
renderWithProviders(<AuditPage />);
diff --git
a/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
b/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
new file mode 100644
index 000000000..eb30fabba
--- /dev/null
+++ b/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
@@ -0,0 +1,244 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from 'antd';
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+import { LangProvider } from '../../../i18n/LangContext';
+import type { AuditRecord } from '../../../api/ops';
+import * as opsService from '../../../services/opsService';
+import { downloadBlob } from '../../../utils/download';
+import ResourceOperationTimelineDrawer, {
+ type AuditTimelineResource,
+} from '../ResourceOperationTimelineDrawer';
+
+vi.mock('../../../services/opsService', () => ({
+ exportAuditLogs: vi.fn(),
+ listAuditRecords: vi.fn(),
+}));
+
+vi.mock('../../../utils/download', () => ({
+ downloadBlob: vi.fn(),
+}));
+
+const resource = {
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+};
+
+const timelineRecord: AuditRecord = {
+ id: 7,
+ timestamp: '2026-08-02 11:00:00',
+ operator: 'ops-user',
+ operationType: 'UPDATE_TOPIC',
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ detail: 'readQueueNums=8',
+ result: 'SUCCESS',
+ errorMessage: '',
+};
+
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
+const renderDrawer = (selectedResource: AuditTimelineResource = resource) =>
+ render(
+ <App>
+ <LangProvider>
+ <ResourceOperationTimelineDrawer open resource={selectedResource}
onClose={vi.fn()} />
+ </LangProvider>
+ </App>,
+ );
+
+describe('ResourceOperationTimelineDrawer', () => {
+ beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ });
+
+ beforeEach(() => {
+ vi.mocked(opsService.listAuditRecords).mockResolvedValue({
+ items: [timelineRecord],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ vi.mocked(opsService.exportAuditLogs).mockResolvedValue('timeline,csv');
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it('loads and exports the exact selected resource timeline', async () => {
+ const user = userEvent.setup();
+ renderDrawer();
+
+ expect(await screen.findByText(/ops-user/)).toBeInTheDocument();
+ expect(opsService.listAuditRecords).toHaveBeenCalledWith({
+ page: 1,
+ pageSize: 20,
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ });
+
+ await user.click(screen.getByRole('button', { name: '导出时间线' }));
+
+ await waitFor(() =>
+ expect(opsService.exportAuditLogs).toHaveBeenCalledWith({
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ }),
+ );
+ expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob),
expect.stringMatching(/\.csv$/));
+ });
+
+ it('keeps resources without a cluster in their own timeline scope', async ()
=> {
+ renderDrawer({ resourceType: 'SETTINGS', target: 'general', clusterId:
null });
+
+ await waitFor(() =>
+ expect(opsService.listAuditRecords).toHaveBeenCalledWith({
+ page: 1,
+ pageSize: 20,
+ resourceType: 'SETTINGS',
+ target: 'general',
+ clusterIdMissing: true,
+ }),
+ );
+ });
+
+ it('loads another server page within the same resource scope', async () => {
+ const user = userEvent.setup();
+ vi.mocked(opsService.listAuditRecords).mockResolvedValue({
+ items: [timelineRecord],
+ total: 21,
+ page: 1,
+ size: 20,
+ });
+ const { container } = renderDrawer();
+ expect(await screen.findByText(/ops-user/)).toBeInTheDocument();
+
+ const secondPage =
container.ownerDocument.querySelector('.ant-pagination-item-2');
+ if (!(secondPage instanceof HTMLElement)) throw new Error('Timeline page 2
was not rendered');
+ await user.click(secondPage);
+
+ await waitFor(() =>
+ expect(opsService.listAuditRecords).toHaveBeenLastCalledWith({
+ page: 2,
+ pageSize: 20,
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ }),
+ );
+ });
+
+ it('applies the selected date range to the resource query and export', async
() => {
+ const user = userEvent.setup();
+ renderDrawer();
+ expect(await screen.findByText(/ops-user/)).toBeInTheDocument();
+ const dateInputs = screen.getAllByLabelText('时间线日期范围');
+
+ await user.type(dateInputs[0], '2026-08-01');
+ await user.keyboard('{Enter}');
+ await user.type(dateInputs[1], '2026-08-02');
+ await user.keyboard('{Enter}');
+
+ await waitFor(() =>
+ expect(opsService.listAuditRecords).toHaveBeenLastCalledWith({
+ page: 1,
+ pageSize: 20,
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ startDate: '2026-08-01',
+ endDate: '2026-08-02',
+ }),
+ );
+
+ await user.click(screen.getByRole('button', { name: '导出时间线' }));
+ await waitFor(() =>
+ expect(opsService.exportAuditLogs).toHaveBeenCalledWith({
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ startDate: '2026-08-01',
+ endDate: '2026-08-02',
+ }),
+ );
+ });
+
+ it('ignores an older timeline response after the selected resource changes',
async () => {
+ const stale = deferred<Awaited<ReturnType<typeof
opsService.listAuditRecords>>>();
+ vi.mocked(opsService.listAuditRecords)
+ .mockImplementationOnce(() => stale.promise)
+ .mockResolvedValueOnce({
+ items: [{ ...timelineRecord, id: 8, target: 'topic-b', operator:
'latest-user' }],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ const { rerender } = renderDrawer();
+
+ rerender(
+ <App>
+ <LangProvider>
+ <ResourceOperationTimelineDrawer
+ open
+ resource={{ ...resource, target: 'topic-b' }}
+ onClose={vi.fn()}
+ />
+ </LangProvider>
+ </App>,
+ );
+
+ expect(await screen.findByText(/latest-user/)).toBeInTheDocument();
+ await act(async () => {
+ stale.resolve({
+ items: [{ ...timelineRecord, operator: 'stale-user' }],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ });
+
+ expect(screen.queryByText(/stale-user/)).not.toBeInTheDocument();
+ expect(screen.getByText(/latest-user/)).toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index 63f43a916..930933f7d 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -60,6 +60,9 @@ import {
} from './auditPresentation';
import AuditSummaryCards from './AuditSummaryCards';
import AuditRiskInsights from './AuditRiskInsights';
+import ResourceOperationTimelineDrawer, {
+ type AuditTimelineResource,
+} from './ResourceOperationTimelineDrawer';
const emptyFilterOptions: AuditFilterOptions = {
operationTypes: [],
@@ -106,6 +109,7 @@ const AuditPage: React.FC = () => {
const [exporting, setExporting] = useState(false);
const [summary, setSummary] = useState<AuditSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(true);
+ const [timelineResource, setTimelineResource] =
useState<AuditTimelineResource | null>(null);
const recordsRequestRef = useRef(0);
const filterOptionsRequestRef = useRef(0);
@@ -355,11 +359,30 @@ const AuditPage: React.FC = () => {
width: 200,
ellipsis: true,
align: 'center',
- render: (_: string, record) => (
- <Tooltip title={describeAuditRecord(record, t)}>
- <span>{record.target || '-'}</span>
- </Tooltip>
- ),
+ render: (_: string, record) => {
+ const target = record.target;
+ if (!target?.trim()) return <Text type="secondary">-</Text>;
+ return (
+ <Tooltip title={describeAuditRecord(record, t)}>
+ <Text
+ strong
+ role="button"
+ tabIndex={0}
+ aria-label={t('audit.timelineView', { target })}
+ style={{ fontSize: 14, cursor: 'pointer' }}
+ onClick={() =>
+ setTimelineResource({
+ resourceType: record.resourceType,
+ target,
+ clusterId: record.clusterId || null,
+ })
+ }
+ >
+ {target}
+ </Text>
+ </Tooltip>
+ );
+ },
},
{
title: t('audit.detail'),
@@ -520,6 +543,14 @@ const AuditPage: React.FC = () => {
/>
</Card>
+ {timelineResource && (
+ <ResourceOperationTimelineDrawer
+ open
+ resource={timelineResource}
+ onClose={() => setTimelineResource(null)}
+ />
+ )}
+
{/* ─── Cleanup Modal ─── */}
<Modal
title={t('audit.cleanupTitle')}
diff --git a/web/src/services/opsService.test.ts
b/web/src/services/opsService.test.ts
index a4a18b878..ce8de492d 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -260,12 +260,18 @@ describe('ops service mock data', () => {
id: 90003,
clusterId: 'prod-other',
};
- insertedRecords.push(matching, otherCluster);
- auditRecords.push(matching, otherCluster);
+ const otherTarget = {
+ ...matching,
+ id: 90005,
+ target: 'consumer-a-copy',
+ };
+ insertedRecords.push(matching, otherCluster, otherTarget);
+ auditRecords.push(matching, otherCluster, otherTarget);
const options = await getAuditFilterOptions();
const result = await listAuditRecords({
resourceType: 'CONSUMER_GROUP',
+ target: 'consumer-a',
clusterId: 'prod-filter',
pageSize: 100,
});
@@ -277,6 +283,34 @@ describe('ops service mock data', () => {
expect(result.items.map((record) => record.id)).toEqual([90002]);
});
+ it('keeps unscoped resource timelines separate from cluster-scoped records',
async () => {
+ const unscoped = {
+ id: 90006,
+ timestamp: '2026-08-01 11:00:00',
+ operator: 'admin',
+ operationType: 'UPDATE_SETTINGS',
+ resourceType: 'SETTINGS',
+ target: 'general',
+ clusterId: null,
+ detail: 'updated settings',
+ result: 'SUCCESS',
+ errorMessage: '',
+ } as AuditRecord;
+ const clusterScoped = { ...unscoped, id: 90007, clusterId: 'prod-cn' };
+ insertedRecords.push(unscoped, clusterScoped);
+ auditRecords.push(unscoped, clusterScoped);
+
+ const result = await listAuditRecords({
+ resourceType: 'SETTINGS',
+ target: 'general',
+ clusterId: 'ignored-cluster',
+ clusterIdMissing: true,
+ pageSize: 100,
+ });
+
+ expect(result.items.map((record) => record.id)).toEqual([90006]);
+ });
+
it('exports filtered audit records as escaped CSV', async () => {
const record = {
id: 90004,
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 0619f7de9..43e2b9a24 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -3,7 +3,12 @@ import {
fetchAuditFilterOptions,
fetchAuditSummary,
} from '../api/audit';
-import type { AuditFilter, AuditFilterOptions, AuditSummary } from
'../api/audit';
+import type {
+ AuditFilter,
+ AuditFilterOptions,
+ AuditSummary,
+ AuditSummaryFilter,
+} from '../api/audit';
import { isMockMode } from './dataMode';
import * as opsApi from '../api/ops';
import type {
@@ -86,7 +91,11 @@ function filterAuditRecords(params: AuditFilter):
AuditRecord[] {
}
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.target && record.target !== params.target) return false;
+ if (!params.clusterIdMissing && params.clusterId && record.clusterId !==
params.clusterId)
+ return false;
+ if (params.clusterIdMissing && record.clusterId != null &&
record.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();
@@ -497,7 +506,7 @@ export async function exportAuditLogs(params: AuditFilter =
{}): Promise<string>
return formatAuditCsv(filterAuditRecords(params));
}
-export async function getAuditSummary(params: AuditFilter = {}):
Promise<AuditSummary> {
+export async function getAuditSummary(params: AuditSummaryFilter = {}):
Promise<AuditSummary> {
if (!isMockMode()) return fetchAuditSummary(params);
const records = filterAuditRecords(params);
const countBy = (field: 'operationType' | 'resourceType') =>