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') =>

Reply via email to