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 1ebfa8d3a feat(web,ops): users page redesign, instance region filter, 
audit op names and cluster-alerts hub (#7366)
1ebfa8d3a is described below

commit 1ebfa8d3a87ab222a95fd622e77772cb4c2d8b58
Author: lizhimins <[email protected]>
AuthorDate: Wed Oct 28 18:12:38 2026 +0800

    feat(web,ops): users page redesign, instance region filter, audit op names 
and cluster-alerts hub (#7366)
    
    * feat(web): 用户管理页重设计、实例列表地域筛选与审计高频操作本地化
    
    - 用户管理:修操作列横向溢出裁切;启用/禁用开关归位状态列、角色开关留权限列;
      列对齐按规范(标签型居中、读数型靠右);size=small 行高 57→41;概览改单行指标条;
      删除孤立「我的账号」Card,修改我的密码上移页头;会话弹窗加宽至 1300 消除横向滚动,
      状态标签去「活跃 + 长时间未活跃」矛盾(异常标记优先、活跃作兜底)
    - 实例列表:筛选条加「按地域筛选」(选项取自已加载实例,纯前端过滤);架构筛选补 aria-label
    - 审计概览:高频操作 / 资源榜改用本地化操作名(创建 Topic 等),每行压成单行;
      补 5 个告警投递类操作码与 ALERT_NOTIFICATION 资源的呈现和文案
    - 测试:新增对齐 / 列宽 / 地域筛选 / 榜单本地化守护用例,既有断言零破坏
    
    * feat(audit): 审计查询支持多操作码过滤(operationTypes)
    
    - /api/audit-logs 新增逗号串 operationTypes 参数,Controller → Service → Repository
      的 applyFilters 加 IN 条件;空值传 null,单操作码路径行为不变
    - 前端 AuditQuery 增加 operationTypes(数组逗号拼接传输),供集群事件 Tab 一次取多类控制面操作
    - 同步修正 AuditListToolHandler 与 4 个测试类的调用签名
    
    * feat(ops): 集群告警枢纽页(四 Tab)与集群事件 Tab
    
    - 集群告警 / 告警事件 / 告警投递 三个路由统一渲染枢纽页 clusterAlertsHub(四个 Tab,
      destroyInactiveTabPane 切走即卸载),侧边栏删掉告警事件、告警投递两项,旧 URL 落到对应 Tab
    - 子页加 embedded 形态:无页头与外层 padding;页头操作区经 portal 挂进 Tab 栏右侧
      (tabBarExtraContent),与页签同一行,不单独占第二行;批量确认按钮尺寸与同行按钮统一
    - 集群事件 Tab 复用运维审计已记录的控制面变更(Broker / 集群配置修改、重启、Proxy 配置与地址增删),
      按 operationTypes 多操作码过滤取数;告警事件表操作列加宽至 240 消除右对齐内容左溢遮挡时间列
---
 .../tool/handler/audit/AuditListToolHandler.java   |   2 +-
 .../rocketmq/studio/ops/audit/AuditController.java |   4 +-
 .../rocketmq/studio/ops/audit/AuditRepository.java |   2 +
 .../rocketmq/studio/ops/audit/AuditService.java    |  32 ++-
 .../ops/audit/MybatisPlusAuditRepository.java      |  12 +-
 .../handler/audit/AuditListToolHandlerTest.java    |   4 +-
 .../studio/ops/audit/AuditControllerTest.java      |  12 +-
 .../studio/ops/audit/AuditServiceTest.java         |  42 +--
 .../ops/audit/MybatisPlusAuditRepositoryTest.java  |  12 +-
 web/src/App.tsx                                    |  12 +-
 web/src/api/ops.ts                                 |  11 +-
 web/src/i18n/translations.ts                       |  35 ++-
 web/src/layouts/MainLayout.tsx                     |  12 -
 .../pages/instance/__tests__/InstancePage.test.tsx |  39 ++-
 web/src/pages/instance/index.tsx                   |  34 ++-
 web/src/pages/ops/AuditSummaryCards.tsx            |  51 ++--
 web/src/pages/ops/__tests__/AuditPage.test.tsx     |   2 +-
 .../pages/ops/__tests__/AuditSummaryCards.test.tsx |   9 +
 web/src/pages/ops/__tests__/clusterEvents.test.tsx | 105 ++++++++
 web/src/pages/ops/alerts.tsx                       | 128 ++++-----
 web/src/pages/ops/auditPresentation.ts             |  23 ++
 web/src/pages/ops/clusterAlertsHub.tsx             |  74 ++++++
 web/src/pages/ops/clusterEvents.tsx                | 200 ++++++++++++++
 web/src/pages/ops/notificationDeliveries.tsx       |   8 +-
 web/src/pages/ops/systemAlerts.tsx                 | 173 +++++++------
 web/src/pages/studio/UserManagement.tsx            | 288 ++++++++++++---------
 .../pages/studio/__tests__/UserManagement.test.tsx |  38 +++
 27 files changed, 1018 insertions(+), 346 deletions(-)

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 41794f36b..0a1954b32 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
@@ -52,7 +52,7 @@ public class AuditListToolHandler implements 
ToolHandler<AuditListInput, PageOut
     public PageOutput<AuditItem> execute(AuditListInput input, 
ToolExecutionContext context) {
         PageResult<AuditRecordVO> result = auditService.queryLogs(
                 input.page().page(), input.page().pageSize(),
-                input.search(), null, input.operationType(), 
input.resourceType(),
+                input.search(), null, input.operationType(), null, 
input.resourceType(),
                 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 7f3311828..61804252b 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
@@ -41,6 +41,7 @@ public class AuditController {
             @RequestParam(required = false) String search,
             @RequestParam(required = false) String operator,
             @RequestParam(required = false) String operationType,
+            @RequestParam(required = false) String operationTypes,
             @RequestParam(required = false) String resourceType,
             @RequestParam(required = false) String target,
             @RequestParam(required = false) String clusterId,
@@ -49,7 +50,8 @@ public class AuditController {
             @RequestParam(required = false) String endDate,
             @RequestParam(required = false) String result) {
         return Result.ok(auditService.queryLogs(page, pageSize, search, 
operator, operationType,
-                resourceType, target, clusterId, clusterIdMissing, startDate, 
endDate, result));
+                operationTypes, resourceType, target, clusterId, 
clusterIdMissing, startDate,
+                endDate, result));
     }
 
     @GetMapping("/filter-options")
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 0f9aeddfb..843fc6396 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
@@ -17,10 +17,12 @@
 package org.apache.rocketmq.studio.ops.audit;
 
 import java.time.LocalDateTime;
+import java.util.List;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 
 public interface AuditRepository {
     PageResult<AuditRecordVO> findPage(String search, String operator, String 
operationType,
+                                       List<String> operationTypes,
                                        String resourceType, String target, 
String clusterId,
                                        boolean clusterIdMissing,
                                        LocalDateTime startDate, LocalDateTime 
endDate,
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 d1a6a5582..9acd400a7 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
@@ -33,6 +33,8 @@ import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
+import java.util.Arrays;
+import java.util.List;
 
 @Slf4j
 @Service
@@ -56,7 +58,8 @@ public class AuditService {
 
 
     public PageResult<AuditRecordVO> queryLogs(int page, int pageSize, String 
search, String operator,
-                                             String operationType, String 
resourceType,
+                                             String operationType, String 
operationTypes,
+                                             String resourceType,
                                              String target, String clusterId, 
boolean clusterIdMissing,
                                              String startDate,
                                              String endDate, String result) {
@@ -64,7 +67,8 @@ public class AuditService {
         log.info("Querying audit logs, page={}, pageSize={}, search={}, 
operator={}, operationType={}, result={}",
                 page, pageSize, search, operator, operationType, result);
 
-        PageResult<AuditRecordVO> stored = findPage(search, operator, 
operationType, resourceType, target,
+        PageResult<AuditRecordVO> stored = findPage(search, operator, 
operationType,
+                splitOperationTypes(operationTypes), resourceType, target,
                 clusterId, clusterIdMissing, startDate, endDate, result, page, 
pageSize);
         if (stored == null || stored.getItems() == null) {
             return stored;
@@ -102,8 +106,8 @@ public class AuditService {
                              String target, String clusterId, boolean 
clusterIdMissing, String startDate,
                              String endDate, String result) {
         PageResult<AuditRecordVO> page = findPage(
-                search, operator, operationType, resourceType, target, 
clusterId, clusterIdMissing,
-                startDate, endDate, result, 1, MAX_EXPORT_RECORDS);
+                search, operator, operationType, null, resourceType, target, 
clusterId,
+                clusterIdMissing, startDate, endDate, result, 1, 
MAX_EXPORT_RECORDS);
         if (page.getTotal() > MAX_EXPORT_RECORDS) {
             throw new BusinessException(400,
                     "Audit log export exceeds the maximum of " + 
MAX_EXPORT_RECORDS + " records; narrow the filters");
@@ -217,17 +221,33 @@ public class AuditService {
     }
 
     private PageResult<AuditRecordVO> findPage(String search, String operator, 
String operationType,
+                                               List<String> operationTypes,
                                                String resourceType, String 
target, String clusterId,
                                                boolean clusterIdMissing,
                                                String startDate, String 
endDate,
                                                String result, int page, int 
pageSize) {
         DateRange range = parseDateRange(startDate, endDate);
         String normalizedSearch = normalizeSearch(search);
-        return auditRepository.findPage(normalizedSearch, operator, 
operationType, resourceType, target,
-                clusterId, clusterIdMissing,
+        return auditRepository.findPage(normalizedSearch, operator, 
operationType, operationTypes,
+                resourceType, target, clusterId, clusterIdMissing,
                 range.start(), range.end(), result, page, pageSize);
     }
 
+    /**
+     * The cluster-events view needs several operation codes at once while 
every other caller
+     * filters by a single one, so the multi-value form rides the query string 
comma-joined.
+     * An absent filter stays {@code null} so the repository's single-value 
path is unchanged.
+     */
+    private List<String> splitOperationTypes(String operationTypes) {
+        if (!StringUtils.hasText(operationTypes)) {
+            return null;
+        }
+        return Arrays.stream(operationTypes.split(","))
+                .map(String::trim)
+                .filter(StringUtils::hasText)
+                .toList();
+    }
+
     private DateRange parseDateRange(String startDate, String endDate) {
         LocalDateTime start = parseDate(startDate, true, "startDate");
         LocalDateTime end = parseDate(endDate, false, "endDate");
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 b9fefd1db..2a1cccf02 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
@@ -55,13 +55,14 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
 
     @Override
     public PageResult<AuditRecordVO> findPage(String search, String operator, 
String operationType,
+                                              List<String> operationTypes,
                                               String resourceType, String 
target, String clusterId,
                                               boolean clusterIdMissing,
                                               LocalDateTime startDate, 
LocalDateTime endDate,
                                               String result, int page, int 
pageSize) {
         QueryWrapper<RmqOperationAudit> query = new QueryWrapper<>();
-        applyFilters(query, search, operator, operationType, resourceType, 
target, clusterId,
-                clusterIdMissing, startDate, endDate, result);
+        applyFilters(query, search, operator, operationType, operationTypes, 
resourceType, target,
+                clusterId, clusterIdMissing, startDate, endDate, result);
         query.orderByDesc("gmt_create", "id");
         Page<RmqOperationAudit> resultPage = auditMapper.selectPage(
                 new Page<>(page, pageSize), query);
@@ -109,7 +110,8 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
                                     LocalDateTime startDate, LocalDateTime 
endDate,
                                     String result) {
         Consumer<QueryWrapper<RmqOperationAudit>> filters = query -> 
applyFilters(query, search,
-                operator, operationType, resourceType, null, clusterId, false, 
startDate, endDate, result);
+                operator, operationType, null, 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
@@ -248,7 +250,8 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
      * hotspot queries so a card can never disagree with the rows it describes.
      */
     private void applyFilters(QueryWrapper<RmqOperationAudit> query, String 
search,
-                              String operator, String operationType, String 
resourceType,
+                              String operator, String operationType, 
List<String> operationTypes,
+                              String resourceType,
                               String target, String clusterId, boolean 
clusterIdMissing,
                               LocalDateTime startDate, LocalDateTime endDate, 
String result) {
         String searchPattern = StringUtils.hasText(search) ? 
LikePatterns.escape(search) : null;
@@ -258,6 +261,7 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
                         .or().like("detail", searchPattern))
                 .eq(StringUtils.hasText(operator), "operator", operator)
                 .eq(StringUtils.hasText(operationType), "operation", 
operationType)
+                .in(operationTypes != null && !operationTypes.isEmpty(), 
"operation", operationTypes)
                 .eq(StringUtils.hasText(resourceType), "resource_type", 
resourceType)
                 .eq(StringUtils.hasText(target), "resource_name", target)
                 .eq(!clusterIdMissing && StringUtils.hasText(clusterId), 
"cluster_id", clusterId)
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 2717e67a0..14132f2b0 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
@@ -61,7 +61,7 @@ class AuditListToolHandlerTest {
                 .result("SUCCESS")
                 .build();
         record.setId(42L);
-        when(auditService.queryLogs(eq(1), eq(20), eq("topic"), isNull(), 
eq("DELETE"), eq("TOPIC"),
+        when(auditService.queryLogs(eq(1), eq(20), eq("topic"), isNull(), 
eq("DELETE"), isNull(), eq("TOPIC"),
                 isNull(), isNull(), eq(false), eq("2026-09-01"), 
eq("2026-09-14"), eq("SUCCESS")))
                 .thenReturn(PageResult.of(List.of(record), 1, 1, 20));
 
@@ -76,7 +76,7 @@ class AuditListToolHandlerTest {
         assertThat(item.operationType()).isEqualTo("DELETE_TOPIC");
         // 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"), isNull(), 
eq("DELETE"), eq("TOPIC"),
+        verify(auditService).queryLogs(eq(1), eq(20), eq("topic"), isNull(), 
eq("DELETE"), isNull(), eq("TOPIC"),
                 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 3e7345097..d26c63706 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
@@ -70,7 +70,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
                 .target("topic-a")
                 .result("SUCCESS")
                 .build();
-        when(auditService.queryLogs(eq(2), eq(10), eq("topic"), eq("admin"), 
eq("DELETE"),
+        when(auditService.queryLogs(eq(2), eq(10), eq("topic"), eq("admin"), 
eq("DELETE"), isNull(),
                 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));
 
@@ -92,7 +92,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
                 
.andExpect(jsonPath("$.data.items[0].operationType").value("DELETE"))
                 .andExpect(jsonPath("$.data.total").value(1));
 
-        verify(auditService).queryLogs(eq(2), eq(10), eq("topic"), 
eq("admin"), eq("DELETE"),
+        verify(auditService).queryLogs(eq(2), eq(10), eq("topic"), 
eq("admin"), eq("DELETE"), isNull(),
                 eq("TOPIC"), eq("topic-a"), eq("prod-cn"), eq(false), 
eq("2026-07-01"), eq("2026-07-24"), eq("SUCCESS"));
     }
 
@@ -184,7 +184,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
     @Test
     void queryLogsShouldUseDefaultPagination() throws Exception {
         when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), isNull(),
-                isNull(), eq(false), isNull(), isNull(), isNull()))
+                isNull(), isNull(), eq(false), isNull(), isNull(), isNull()))
                 .thenReturn(PageResult.of(List.of(), 0, 1, 20));
 
         mockMvc.perform(get("/api/audit-logs"))
@@ -194,12 +194,12 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
                 .andExpect(jsonPath("$.data.size").value(20));
 
         verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), isNull(),
-                isNull(), eq(false), isNull(), isNull(), isNull());
+                isNull(), isNull(), eq(false), isNull(), isNull(), isNull());
     }
 
     @Test
     void queryLogsShouldForwardAnExplicitMissingClusterScopeTest() throws 
Exception {
-        when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), eq("SETTINGS"),
+        when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), eq("SETTINGS"),
                 eq("general"), isNull(), eq(true), isNull(), isNull(), 
isNull()))
                 .thenReturn(PageResult.of(List.of(), 0, 1, 20));
 
@@ -209,7 +209,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
                         .param("clusterIdMissing", "true"))
                 .andExpect(status().isOk());
 
-        verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), eq("SETTINGS"),
+        verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), eq("SETTINGS"),
                 eq("general"), isNull(), eq(true), isNull(), isNull(), 
isNull());
     }
 
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 4cbe5bd10..afa8d2742 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
@@ -91,16 +91,16 @@ class AuditServiceTest {
     @Test
     void queryLogsDelegatesPaginationAndFiltersToRepository() {
         AuditRecordVO record = 
AuditRecordVO.builder().operationType("CREATE").build();
-        when(auditRepository.findPage(eq("topic-a"), eq("admin"), 
eq("CREATE"), eq("TOPIC"), eq("prod-cn"),
+        when(auditRepository.findPage(eq("topic-a"), eq("admin"), 
eq("CREATE"), isNull(), eq("TOPIC"), eq("prod-cn"),
                 isNull(), 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", "admin", "CREATE", "TOPIC", "prod-cn", null, 
false, null, null, "SUCCESS");
+                2, 20, "topic-a", "admin", "CREATE", null, "TOPIC", "prod-cn", 
null, false, null, null, "SUCCESS");
 
         assertThat(result.getItems()).containsExactly(record);
         assertThat(result.getTotal()).isEqualTo(21);
-        verify(auditRepository).findPage(eq("topic-a"), eq("admin"), 
eq("CREATE"), eq("TOPIC"), eq("prod-cn"),
+        verify(auditRepository).findPage(eq("topic-a"), eq("admin"), 
eq("CREATE"), isNull(), eq("TOPIC"), eq("prod-cn"),
                 isNull(), eq(false), isNull(), isNull(), eq("SUCCESS"), eq(2), 
eq(20));
     }
 
@@ -118,11 +118,11 @@ class AuditServiceTest {
                     .timestamp(LocalDateTime.of(2026, 8, 1, 10, 0, 0))
                     .build();
             when(auditRepository.findPage(isNull(), isNull(), isNull(), 
isNull(), isNull(),
-                    isNull(), eq(false), isNull(), isNull(), isNull(), eq(1), 
eq(20)))
+                    isNull(), isNull(), eq(false), isNull(), isNull(), 
isNull(), eq(1), eq(20)))
                     .thenReturn(PageResult.of(List.of(stored), 1, 1, 20));
 
             PageResult<AuditRecordVO> result = auditService.queryLogs(
-                    1, 20, null, null, null, null, null, null, false, null, 
null, null);
+                    1, 20, null, null, null, null, null, null, null, false, 
null, null, null);
 
             assertThat(result.getItems().get(0).getTimestamp())
                     .isEqualTo(LocalDateTime.of(2026, 8, 1, 2, 0, 0));
@@ -158,17 +158,17 @@ class AuditServiceTest {
     @Test
     void queryLogsParsesDateRangeBeforeDelegating() {
         when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
-                isNull(), isNull(), eq(false), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                isNull(), isNull(), 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, null, null, 
false,
+        auditService.queryLogs(1, 10, null, null, 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(),
-                isNull(), isNull(), eq(false), start.capture(), end.capture(), 
isNull(), eq(1), eq(10));
+                isNull(), isNull(), 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));
     }
@@ -176,11 +176,11 @@ class AuditServiceTest {
     @Test
     void queryLogsRejectsInvalidPageBounds() {
         assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null, 
null, null, null,
-                null, false, 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, false, null, null, null))
+                null, null, false, null, null, null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("pageSize must be between 1 and 100");
     }
@@ -188,7 +188,7 @@ class AuditServiceTest {
     @Test
     void queryLogsRejectsInvalidDateRange() {
         assertThatThrownBy(() -> auditService.queryLogs(1, 10, null, null, 
null, null, null,
-                null, false, "2026-08-02", "2026-08-01", null))
+                null, null, false, "2026-08-02", "2026-08-01", null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("startDate must not be after endDate");
     }
@@ -206,7 +206,7 @@ class AuditServiceTest {
                 .result("FAILED")
                 .errorMessage("=denied")
                 .build();
-        when(auditRepository.findPage(eq("topic"), eq("admin"), eq("DELETE"), 
eq("TOPIC"), eq("prod-cn"),
+        when(auditRepository.findPage(eq("topic"), eq("admin"), eq("DELETE"), 
isNull(), eq("TOPIC"), eq("prod-cn"),
                 isNull(), 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));
 
@@ -228,7 +228,7 @@ class AuditServiceTest {
                 .target("topic-a")
                 .result("SUCCESS")
                 .build();
-        when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(), 
isNull(), isNull(), eq(false),
+        when(auditRepository.findPage(isNull(), isNull(), 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));
 
@@ -252,7 +252,7 @@ class AuditServiceTest {
     @Test
     void exportLogsRejectsResultsBeyondBound() {
         when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(), 
isNull(),
-                isNull(), eq(false), isNull(), isNull(), isNull(), eq(1), 
eq(10_000)))
+                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(
@@ -333,36 +333,36 @@ class AuditServiceTest {
 
     @Test
     void queryLogsShouldTrimSearchTermBeforeDelegating() {
-        auditService.queryLogs(1, 10, "  ops  ", null, null, null, null, null, 
false,
+        auditService.queryLogs(1, 10, "  ops  ", null, null, null, null, null, 
null, false,
                 null, null, null);
 
         ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);
         verify(auditRepository).findPage(search.capture(), isNull(), isNull(), 
isNull(), isNull(),
-                isNull(), eq(false), isNull(), isNull(), isNull(), eq(1), 
eq(10));
+                isNull(), isNull(), eq(false), isNull(), isNull(), isNull(), 
eq(1), eq(10));
         assertThat(search.getValue()).isEqualTo("ops");
     }
 
     @Test
     void queryLogsShouldTreatWhitespaceOnlySearchAsAbsent() {
-        auditService.queryLogs(1, 10, "   ", null, null, null, null, null, 
false,
+        auditService.queryLogs(1, 10, "   ", null, null, null, null, null, 
null, false,
                 null, null, null);
 
         ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);
         verify(auditRepository).findPage(search.capture(), isNull(), isNull(), 
isNull(), isNull(),
-                isNull(), eq(false), isNull(), isNull(), isNull(), eq(1), 
eq(10));
+                isNull(), isNull(), eq(false), isNull(), isNull(), isNull(), 
eq(1), eq(10));
         assertThat(search.getValue()).isNull();
     }
 
     @Test
     void exportLogsShouldTrimSearchTermBeforeDelegating() {
-        when(auditRepository.findPage(any(), any(), any(), any(), any(), 
any(), anyBoolean(),
+        when(auditRepository.findPage(any(), any(), any(), any(), any(), 
any(), any(), anyBoolean(),
                 any(), any(), any(), anyInt(), 
anyInt())).thenReturn(PageResult.empty(1, 10));
 
         auditService.exportLogs("  50% off  ", null, null, null, null, null, 
false, null, null, null);
 
         ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);
-        verify(auditRepository).findPage(search.capture(), any(), any(), 
any(), any(), any(), anyBoolean(),
-                any(), any(), any(), anyInt(), anyInt());
+        verify(auditRepository).findPage(search.capture(), any(), any(), 
any(), any(), any(), any(),
+                anyBoolean(), any(), any(), any(), anyInt(), anyInt());
         assertThat(search.getValue()).isEqualTo("50% off");
     }
 
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 0e8d62599..aa78663b3 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,7 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), 
any(Wrapper.class))).thenReturn(mapperPage);
 
         PageResult<AuditRecordVO> result = repository.findPage(
-                "orders", "admin", "DELETE_TOPIC", "TOPIC", null, "prod-cn", 
false, null, null, "FAILED", 2, 25);
+                "orders", "admin", "DELETE_TOPIC", null, "TOPIC", null, 
"prod-cn", false, null, null, "FAILED", 2, 25);
 
         ArgumentCaptor<IPage<RmqOperationAudit>> pageCaptor = 
ArgumentCaptor.forClass(IPage.class);
         ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
@@ -91,7 +91,7 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
                 .thenReturn(new Page<RmqOperationAudit>(1, 
20).setRecords(List.of()).setTotal(0));
 
-        repository.findPage(null, null, null, "TOPIC", "orders", "prod-cn", 
false,
+        repository.findPage(null, null, null, null, "TOPIC", "orders", 
"prod-cn", false,
                 null, null, null, 1, 20);
 
         ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
@@ -106,7 +106,7 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
                 .thenReturn(new Page<RmqOperationAudit>(1, 
20).setRecords(List.of()).setTotal(0));
 
-        repository.findPage(null, "ops-zhang", null, null, null, null, false,
+        repository.findPage(null, "ops-zhang", null, null, null, null, null, 
false,
                 null, null, null, 1, 20);
 
         ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
@@ -126,7 +126,7 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
                 .thenReturn(new Page<RmqOperationAudit>(1, 
20).setRecords(List.of()).setTotal(0));
 
-        repository.findPage(null, null, null, "SETTINGS", "general", null, 
true,
+        repository.findPage(null, null, null, null, "SETTINGS", "general", 
null, true,
                 null, null, null, 1, 20);
 
         ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
@@ -140,7 +140,7 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
                 .thenReturn(new Page<RmqOperationAudit>(1, 
20).setRecords(List.of()).setTotal(0));
 
-        repository.findPage(null, null, null, "SETTINGS", "general", 
"ignored-cluster", true,
+        repository.findPage(null, null, null, null, "SETTINGS", "general", 
"ignored-cluster", true,
                 null, null, null, 1, 20);
 
         ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
@@ -377,7 +377,7 @@ class MybatisPlusAuditRepositoryTest {
         when(auditMapper.selectPage(any(IPage.class), any(Wrapper.class)))
                 .thenReturn(new Page<RmqOperationAudit>(1, 
20).setRecords(List.of()).setTotal(0));
 
-        repository.findPage("topic_%", null, null, null, null, null, false,
+        repository.findPage("topic_%", null, null, null, null, null, null, 
false,
                 null, null, null, 1, 20);
 
         ArgumentCaptor<Wrapper<RmqOperationAudit>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 4e7284fe3..a3170a090 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -42,8 +42,7 @@ const K8sCertsPage = lazy(() => 
import('./pages/cluster/certs'));
 const ClientsPage = lazy(() => import('./pages/cluster/clients'));
 const DashboardOpsPage = lazy(() => import('./pages/home/dashboard'));
 const AlertsPage = lazy(() => import('./pages/ops/alerts'));
-const SystemAlertsPage = lazy(() => import('./pages/ops/systemAlerts'));
-const NotificationDeliveriesPage = lazy(() => 
import('./pages/ops/notificationDeliveries'));
+const ClusterAlertsHub = lazy(() => import('./pages/ops/clusterAlertsHub'));
 const AuditPage = lazy(() => import('./pages/ops/audit'));
 const AiPage = lazy(() => import('./pages/ai'));
 const SettingsPage = lazy(() => import('./pages/settings'));
@@ -210,10 +209,13 @@ function App() {
             <Route path="cluster/clients" element={<ClientsPage />} />
             <Route path="ops/dashboard" element={<DashboardOpsPage />} />
             <Route path="ops/grafana" element={<GrafanaDashboardsPage />} />
-            <Route path="ops/alerts" element={<AlertsPage />} />
+            <Route path="ops/alerts" element={<ClusterAlertsHub 
initialTab="rules" />} />
             <Route path="ops/business-alerts" element={<AlertsPage 
domain="BUSINESS" />} />
-            <Route path="ops/system-alerts" element={<SystemAlertsPage />} />
-            <Route path="ops/alert-deliveries" 
element={<NotificationDeliveriesPage />} />
+            <Route path="ops/system-alerts" element={<ClusterAlertsHub 
initialTab="events" />} />
+            <Route
+              path="ops/alert-deliveries"
+              element={<ClusterAlertsHub initialTab="deliveries" />}
+            />
             <Route path="ops/audit" element={<AuditPage />} />
             <Route path="ai" element={<AiPage />} />
             <Route path="ai/c/:conversationId" element={<AiPage />} />
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 37de97b72..c456e7ae2 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -215,6 +215,11 @@ export interface AuditQuery {
   search?: string;
   operator?: string;
   operationType?: string;
+  /**
+   * Several operation codes at once (cluster-events view); sent comma-joined 
because the
+   * backend takes the multi-value form as one query-string parameter.
+   */
+  operationTypes?: string[];
   resourceType?: string;
   target?: string;
   clusterId?: string;
@@ -408,8 +413,12 @@ export async function deleteAlertSilence(id: number) {
 
 // ─── Audit Logs ─────────────────────────────────────────────────
 export async function listAuditRecords(params?: AuditQuery) {
+  const { operationTypes, ...rest } = params ?? {};
   const res = await client.get<{ data: PageResult<AuditRecord> 
}>('/audit-logs', {
-    params,
+    params: {
+      ...rest,
+      ...(operationTypes?.length ? { operationTypes: operationTypes.join(',') 
} : {}),
+    },
   });
   return res.data.data;
 }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 98773649f..6d65487b9 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -34,6 +34,7 @@ const translations: Record<string, Record<Lang, string>> = {
   'nav.clients': { zh: '客户端连接', en: 'Client Connections' },
   'nav.alertEvents': { zh: '告警事件', en: 'Alert Events' },
   'nav.alertDeliveries': { zh: '告警投递', en: 'Alert Deliveries' },
+  'nav.clusterEvents': { zh: '集群事件', en: 'Cluster Events' },
   'nav.alertRules': { zh: '集群告警', en: 'Cluster Alerts' },
   'nav.alertRuleAssets': { zh: '业务告警', en: 'Business Alerts' },
   'nav.audit': { zh: '审计日志', en: 'Audit Log' },
@@ -302,6 +303,9 @@ const translations: Record<string, Record<Lang, string>> = {
   'instance.count': { zh: '共 {n} 个实例', en: '{n} instances' },
   'instance.searchPlaceholder': { zh: '搜索实例 ID 或地址', en: 'Search instance ID 
or endpoint' },
   'instance.allTypes': { zh: '全部架构', en: 'All Types' },
+  'instance.allRegions': { zh: '全部地域', en: 'All Regions' },
+  'instance.filterByRegion': { zh: '按地域筛选', en: 'Filter by region' },
+  'instance.filterByType': { zh: '按架构筛选', en: 'Filter by architecture' },
   'instance.cloudType': { zh: '云服务', en: 'Cloud Service' },
   'instance.proxyLocalMode': { zh: 'Proxy Local 模式', en: 'Proxy Local Mode' },
   'instance.proxyClusterMode': { zh: 'Proxy Cluster 模式', en: 'Proxy Cluster 
Mode' },
@@ -1685,6 +1689,33 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'audit.op.ACKNOWLEDGE_SYSTEM_ALERT': { zh: '确认系统告警', en: 'Acknowledge System 
Alert' },
   'audit.op.CREATE_ALERT_SILENCE': { zh: '创建告警静默', en: 'Create Alert Silence' 
},
   'audit.op.DELETE_ALERT_SILENCE': { zh: '删除告警静默', en: 'Delete Alert Silence' 
},
+  'audit.op.DELIVER_ALERT_NOTIFICATION': { zh: '投递告警通知', en: 'Deliver Alert 
Notification' },
+  'audit.op.RETRY_ALERT_NOTIFICATION': { zh: '重试投递告警', en: 'Retry Alert 
Notification' },
+  'audit.op.RETRY_ALERT_NOTIFICATION_MANUALLY': {
+    zh: '手动重试投递告警',
+    en: 'Retry Alert Notification Manually',
+  },
+  'audit.op.FAIL_ALERT_NOTIFICATION': { zh: '告警投递失败', en: 'Fail Alert 
Notification' },
+  'audit.op.SUMMARY_DASHBOARD': { zh: '查看监控面板', en: 'View Summary Dashboard' },
+
+  'clusterAlerts.subtitle': {
+    zh: '告警规则、告警事件、投递记录与集群事件',
+    en: 'Alert rules, alert events, delivery records and cluster events',
+  },
+  'clusterEvents.time': { zh: '时间', en: 'Time' },
+  'clusterEvents.event': { zh: '事件', en: 'Event' },
+  'clusterEvents.target': { zh: '对象', en: 'Target' },
+  'clusterEvents.cluster': { zh: '集群', en: 'Cluster' },
+  'clusterEvents.operator': { zh: '操作人', en: 'Operator' },
+  'clusterEvents.result': { zh: '结果', en: 'Result' },
+  'clusterEvents.detail': { zh: '详情', en: 'Detail' },
+  'clusterEvents.total': { zh: '共 {count} 条事件', en: '{count} events in total' 
},
+  'clusterEvents.loadFailed': { zh: '加载集群事件失败', en: 'Failed to load cluster 
events' },
+  'clusterEvents.bannerTitle': { zh: '集群事件来源', en: 'Where cluster events come 
from' },
+  'clusterEvents.bannerDescription': {
+    zh: '集群事件取自运维审计中记录的控制面变更:Broker / 集群配置修改、Broker 重启、Proxy 配置重载与地址增删。',
+    en: 'Cluster events are the control-plane changes recorded in the ops 
audit: broker / cluster config updates, broker restarts, proxy config reloads 
and proxy address changes.',
+  },
   'audit.op.IMPORT_CLOUD_INSTANCES': { zh: '导入云端实例', en: 'Import Cloud 
Instances' },
   'audit.op.UPDATE_GROUP_SETTINGS': { zh: '更新消费组设置', en: 'Update Group 
Settings' },
   'audit.op.CLEAR_ACKNOWLEDGED_SYSTEM_ALERTS': {
@@ -1714,6 +1745,7 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'audit.res.ALERT_SILENCE': { zh: '告警静默', en: 'Alert Silence' },
   'audit.res.ALERT_RULE': { zh: '告警规则', en: 'Alert Rule' },
   'audit.res.SYSTEM_ALERT': { zh: '系统告警', en: 'System Alert' },
+  'audit.res.ALERT_NOTIFICATION': { zh: '告警通知', en: 'Alert Notification' },
   'audit.res.K8S_CERTIFICATE': { zh: 'K8s 证书', en: 'K8s Certificate' },
   'audit.result.SUCCESS': { zh: '成功', en: 'Success' },
   'audit.result.PARTIAL': { zh: '部分成功', en: 'Partial' },
@@ -3188,9 +3220,8 @@ const translations: Record<string, Record<Lang, string>> 
= {
     zh: '你可以修改自己的密码;用户列表和账号状态仅对管理员开放。',
     en: 'You can change your own password; the user list and account status 
are only available to administrators.',
   },
-  'userMgmt.myAccount': { zh: '我的账号', en: 'My Account' },
   'userMgmt.changeMyPassword': { zh: '修改我的密码', en: 'Change My Password' },
-  'userMgmt.sessionOverview': { zh: '会话概览', en: 'Session Overview' },
+  'userMgmt.viewSessionsOf': { zh: '查看 {username} 的会话', en: 'View sessions of 
{username}' },
   'userMgmt.sessionOverviewLoadFailed': {
     zh: '加载会话概览失败',
     en: 'Failed to load session overview',
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index aee35e3c9..e7cada05a 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -47,8 +47,6 @@ import {
   TrashSimple,
   PlugsConnected,
   BellRinging,
-  Siren,
-  PaperPlaneTilt,
   Notebook,
   Warning,
   List,
@@ -242,16 +240,6 @@ const MainLayout = () => {
           { key: '/cluster', icon: <Database size={16} />, label: 
t('nav.rocketmqCluster') },
           { key: '/cluster/clients', icon: <PlugsConnected size={16} />, 
label: t('nav.clients') },
           { key: '/ops/alerts', icon: <BellRinging size={16} />, label: 
t('nav.alertRules') },
-          {
-            key: '/ops/system-alerts',
-            icon: <Siren size={16} />,
-            label: t('nav.alertEvents'),
-          },
-          {
-            key: '/ops/alert-deliveries',
-            icon: <PaperPlaneTilt size={16} />,
-            label: t('nav.alertDeliveries'),
-          },
           { key: '/ops/dashboard', icon: <ChartBar size={16} />, label: 
t('nav.dashboard') },
         ],
       },
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 626fe737b..62c6f518b 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -146,6 +146,37 @@ describe('InstancePage', () => {
     ]);
   });
 
+  it('filters the inventory by region on the clientTest', async () => {
+    const user = userEvent.setup();
+    vi.mocked(instanceService.listInstances).mockResolvedValue([
+      {
+        ...instance(1, 'hangzhou-cloud', 'CLOUD'),
+        vendor: 'ALIYUN',
+        regionId: 'cn-hangzhou',
+        regionName: '华东 1(杭州)',
+      },
+      {
+        ...instance(2, 'shanghai-cloud', 'CLOUD'),
+        vendor: 'ALIYUN',
+        regionId: 'cn-shanghai',
+        regionName: '华东 2(上海)',
+      },
+    ]);
+    renderPage();
+    await screen.findByText('hangzhou-cloud');
+    expect(screen.getByText('shanghai-cloud')).toBeInTheDocument();
+
+    await user.click(screen.getByRole('combobox', { name: '按地域筛选' }));
+    await user.click(
+      await screen.findByText('华东 2(上海)', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    await waitFor(() => 
expect(screen.queryByText('hangzhou-cloud')).not.toBeInTheDocument());
+    expect(screen.getByText('shanghai-cloud')).toBeInTheDocument();
+    // 列表一次全量返回、不分页,地域筛选是纯前端过滤,不触发新的列表请求。
+    expect(instanceService.listInstances).toHaveBeenCalledTimes(1);
+  });
+
   it.each([20, 21, 201])('offersEveryCloudCredentialForInventoryOf%sTest', 
async (count) => {
     const user = userEvent.setup();
     const allCredentials: CloudCredential[] = Array.from({ length: count }, 
(_, index) => ({
@@ -424,7 +455,7 @@ describe('InstancePage', () => {
       },
     );
 
-    const typeSelect = screen.getByRole('combobox');
+    const typeSelect = screen.getByRole('combobox', { name: '按架构筛选' });
     fireEvent.mouseDown(typeSelect.parentElement!);
     await user.click(
       await screen.findByText('Direct 模式', { selector: 
'.ant-select-item-option-content' }),
@@ -561,7 +592,7 @@ describe('InstancePage', () => {
     renderPage();
 
     expect(await screen.findByText('production-proxy')).toBeInTheDocument();
-    const typeSelect = screen.getByRole('combobox');
+    const typeSelect = screen.getByRole('combobox', { name: '按架构筛选' });
     fireEvent.mouseDown(typeSelect.parentElement!);
     await user.click(
       await screen.findByText('Direct 模式', { selector: 
'.ant-select-item-option-content' }),
@@ -793,7 +824,7 @@ describe('InstancePage', () => {
       
expect(instanceService.deleteInstance).toHaveBeenCalledWith('production-proxy'),
     );
 
-    const typeSelect = screen.getByRole('combobox');
+    const typeSelect = screen.getByRole('combobox', { name: '按架构筛选' });
     fireEvent.mouseDown(typeSelect.parentElement!);
     await user.click(
       await screen.findByText('Direct 模式', { selector: 
'.ant-select-item-option-content' }),
@@ -1386,7 +1417,7 @@ describe('InstancePage', () => {
     const deleteButton = screen.getAllByRole('button', { name: /删除/ })[0];
     expect(deleteButton).toBeEnabled();
 
-    const typeSelect = screen.getByRole('combobox');
+    const typeSelect = screen.getByRole('combobox', { name: '按架构筛选' });
     fireEvent.mouseDown(typeSelect.parentElement!);
     await user.click(
       await screen.findByText('Direct 模式', { selector: 
'.ant-select-item-option-content' }),
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index f93fd02f4..ecebd2ba2 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import {
   Table,
@@ -129,6 +129,7 @@ const InstancePage = () => {
   const [search, setSearch] = useState('');
   const [debouncedSearch, setDebouncedSearch] = useState('');
   const [typeFilter, setTypeFilter] = useState<InstanceTypeFilter>('ALL');
+  const [regionFilter, setRegionFilter] = useState<string>('ALL');
   const [addModalOpen, setAddModalOpen] = useState(false);
   const [vendor, setVendor] = useState<InstanceVendor>(DEFAULT_VENDOR);
   const [addForm] = Form.useForm();
@@ -195,6 +196,27 @@ const InstancePage = () => {
     };
   }, [debouncedSearch, loadInstances, typeFilter]);
 
+  // 地域选项取自已加载实例(列表不分页、一次全量),开源实例无地域不进选项;
+  // 选中某地域后开源实例因无地域而自然被过滤掉。
+  const regionOptions = useMemo(() => {
+    const seen = new Map<string, string>();
+    for (const instance of instances) {
+      if (!instance.regionId || seen.has(instance.regionId)) continue;
+      seen.set(instance.regionId, instance.regionName || instance.regionId);
+    }
+    return Array.from(seen.entries())
+      .sort(([left], [right]) => left.localeCompare(right))
+      .map(([value, label]) => ({ value, label }));
+  }, [instances]);
+
+  const visibleInstances = useMemo(
+    () =>
+      regionFilter === 'ALL'
+        ? instances
+        : instances.filter((instance) => instance.regionId === regionFilter),
+    [instances, regionFilter],
+  );
+
   const cloudVendor = vendor === 'ALIYUN' || vendor === 'TENCENT';
 
   useEffect(() => {
@@ -788,6 +810,7 @@ const InstancePage = () => {
             value={typeFilter}
             onChange={setTypeFilter}
             style={{ width: 140 }}
+            aria-label={t('instance.filterByType')}
             options={[
               { value: 'ALL', label: t('instance.allTypes') },
               { value: 'CLOUD', label: t('instance.cloudType') },
@@ -796,6 +819,13 @@ const InstancePage = () => {
               { value: 'DIRECT', label: t('instance.directMode') },
             ]}
           />
+          <Select
+            value={regionFilter}
+            onChange={setRegionFilter}
+            style={{ width: 160 }}
+            aria-label={t('instance.filterByRegion')}
+            options={[{ value: 'ALL', label: t('instance.allRegions') }, 
...regionOptions]}
+          />
         </Space>
         <Space size={12}>
           <Button
@@ -821,7 +851,7 @@ const InstancePage = () => {
         <Table
           className="instance-table"
           columns={columns}
-          dataSource={instances}
+          dataSource={visibleInstances}
           loading={loading}
           rowKey="name"
           rowSelection={{
diff --git a/web/src/pages/ops/AuditSummaryCards.tsx 
b/web/src/pages/ops/AuditSummaryCards.tsx
index effff36de..6299d60a3 100644
--- a/web/src/pages/ops/AuditSummaryCards.tsx
+++ b/web/src/pages/ops/AuditSummaryCards.tsx
@@ -18,6 +18,7 @@
 import { Card, Col, Empty, Flex, Progress, Row, Skeleton, Statistic, Tag, 
Typography } from 'antd';
 import type { AuditSummary, AuditSummaryBucket } from '../../api/audit';
 import { useLang } from '../../i18n/LangContext';
+import { getAuditOperationPresentation, getAuditResourcePresentation } from 
'./auditPresentation';
 
 const { Text } = Typography;
 
@@ -26,27 +27,43 @@ interface Props {
   loading: boolean;
 }
 
-const BucketList = ({ items, total }: { items: AuditSummaryBucket[]; total: 
number }) => {
+// 榜项用本地化操作名 / 资源名(如「创建 Topic」),不裸显 CREATE_TOPIC 这类内部码;
+// 每行单行放下(名称 + 内联占比条 + 计数),名称与占比条不再各占一行把卡片撑高。
+const BucketList = ({
+  items,
+  total,
+  kind,
+}: {
+  items: AuditSummaryBucket[];
+  total: number;
+  kind: 'operation' | 'resource';
+}) => {
   const { t } = useLang();
   if (!items.length)
     return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} 
description={t('common.noData')} />;
   return (
-    <Flex vertical gap={10}>
-      {items.slice(0, 5).map((item) => (
-        <div key={item.name}>
-          <Flex justify="space-between" gap={8}>
-            <Text ellipsis title={item.name}>
-              {item.name.replace(/_/g, ' ')}
+    <Flex vertical gap={8}>
+      {items.slice(0, 5).map((item) => {
+        const presentation =
+          kind === 'operation'
+            ? getAuditOperationPresentation(item.name)
+            : getAuditResourcePresentation(item.name);
+        const label = presentation.labelKey ? t(presentation.labelKey) : 
presentation.label;
+        return (
+          <Flex key={item.name} align="center" gap={8}>
+            <Text ellipsis style={{ flex: 1, minWidth: 0 }} title={label}>
+              {label}
             </Text>
-            <Tag>{item.count}</Tag>
+            <Progress
+              percent={total ? Math.round((item.count / total) * 100) : 0}
+              showInfo={false}
+              size="small"
+              style={{ width: 96, margin: 0 }}
+            />
+            <Tag style={{ marginInlineEnd: 0 }}>{item.count}</Tag>
           </Flex>
-          <Progress
-            percent={total ? Math.round((item.count / total) * 100) : 0}
-            showInfo={false}
-            size="small"
-          />
-        </div>
-      ))}
+        );
+      })}
     </Flex>
   );
 };
@@ -102,12 +119,12 @@ const AuditSummaryCards = ({ summary, loading }: Props) 
=> {
       </Col>
       <Col xs={24} lg={12}>
         <Card size="small" title={t('audit.summaryTopOperations')}>
-          <BucketList items={data.byOperation} total={data.total} />
+          <BucketList items={data.byOperation} total={data.total} 
kind="operation" />
         </Card>
       </Col>
       <Col xs={24} lg={12}>
         <Card size="small" title={t('audit.summaryResourceTypes')}>
-          <BucketList items={data.byResourceType} total={data.total} />
+          <BucketList items={data.byResourceType} total={data.total} 
kind="resource" />
         </Card>
       </Col>
     </Row>
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx 
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index f9f4dabd8..3a14f2652 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -325,7 +325,7 @@ describe('Audit page', () => {
 
     expect(await screen.findByText('匹配记录')).toBeInTheDocument();
     expect(screen.getByText('80')).toBeInTheDocument();
-    expect(screen.getAllByText('DELETE TOPIC').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('删除 Topic').length).toBeGreaterThan(0);
     await user.type(screen.getByPlaceholderText('搜索操作人或操作对象'), 'topic-a');
 
     await waitFor(
diff --git a/web/src/pages/ops/__tests__/AuditSummaryCards.test.tsx 
b/web/src/pages/ops/__tests__/AuditSummaryCards.test.tsx
index 249fa28de..f6d3ee724 100644
--- a/web/src/pages/ops/__tests__/AuditSummaryCards.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditSummaryCards.test.tsx
@@ -73,6 +73,15 @@ describe('AuditSummaryCards', () => {
     expect(screen.getByText('资源类型分布')).toBeInTheDocument();
   });
 
+  it('shows localized operation and resource names in the top buckets', () => {
+    renderCards();
+    // 高频操作 / 资源榜用本地化名称(删除 Topic),不裸显 DELETE_TOPIC 这类内部码。
+    expect(screen.getByText('删除 Topic')).toBeInTheDocument();
+    expect(screen.getByText('Topic')).toBeInTheDocument();
+    expect(screen.queryByText('DELETE TOPIC')).not.toBeInTheDocument();
+    expect(screen.queryByText('DELETE_TOPIC')).not.toBeInTheDocument();
+  });
+
   it('renders every card label in English when the console language is 
English', () => {
     localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
     renderCards();
diff --git a/web/src/pages/ops/__tests__/clusterEvents.test.tsx 
b/web/src/pages/ops/__tests__/clusterEvents.test.tsx
new file mode 100644
index 000000000..7a6e846e5
--- /dev/null
+++ b/web/src/pages/ops/__tests__/clusterEvents.test.tsx
@@ -0,0 +1,105 @@
+/*
+ * 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 { render, screen, waitFor } from '@testing-library/react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MemoryRouter } from 'react-router-dom';
+import type { AuditRecord } from '../../../api/ops';
+import { listAuditRecords } from '../../../api/ops';
+import { LangProvider } from '../../../i18n/LangContext';
+import ClusterEventsTab, { CLUSTER_EVENT_OPERATIONS } from '../clusterEvents';
+
+vi.mock('../../../api/ops', () => ({
+  listAuditRecords: vi.fn(),
+}));
+
+const record = (id: number): AuditRecord => ({
+  id,
+  timestamp: '2026-10-28T06:00:00',
+  operator: 'rocketmq',
+  operationType: 'UPDATE_BROKER_CONFIG',
+  resourceType: 'BROKER',
+  target: 'broker-a',
+  clusterId: 'cluster-1',
+  detail: 'brokerRole=SYNC',
+  result: 'SUCCESS',
+  errorMessage: '',
+});
+
+const renderTab = () =>
+  render(
+    <MemoryRouter>
+      <LangProvider>
+        <ClusterEventsTab />
+      </LangProvider>
+    </MemoryRouter>,
+  );
+
+beforeAll(() => {
+  // antd's responsive Table/Pagination subscribes to matchMedia, which jsdom 
lacks.
+  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(),
+    })),
+  });
+});
+
+describe('ClusterEventsTab', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(listAuditRecords).mockResolvedValue({
+      items: [record(1)],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+  });
+
+  it('queries the audit log restricted to the cluster control-plane 
operations', async () => {
+    renderTab();
+
+    await waitFor(() =>
+      expect(listAuditRecords).toHaveBeenCalledWith({
+        operationTypes: CLUSTER_EVENT_OPERATIONS,
+        page: 1,
+        pageSize: 20,
+      }),
+    );
+    expect(CLUSTER_EVENT_OPERATIONS).toContain('UPDATE_BROKER_CONFIG');
+  });
+
+  it('renders the localized event name and the record facts', async () => {
+    renderTab();
+
+    // 事件列用本地化操作名(更新 Broker 配置),不裸显内部码。
+    expect(await screen.findByText('更新 Broker 配置')).toBeInTheDocument();
+    expect(screen.queryByText('UPDATE_BROKER_CONFIG')).not.toBeInTheDocument();
+    expect(screen.getByText('broker-a')).toBeInTheDocument();
+    expect(screen.getByText('cluster-1')).toBeInTheDocument();
+    expect(screen.getByText('rocketmq')).toBeInTheDocument();
+    expect(screen.getByText('成功')).toBeInTheDocument();
+    expect(screen.getByText('共 1 条事件')).toBeInTheDocument();
+  });
+});
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 6b56ebdb7..e25ff85ef 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -16,6 +16,7 @@
  */
 
 import { useCallback, useEffect, useMemo, useRef, useState, type Key } from 
'react';
+import { createPortal } from 'react-dom';
 import {
   Copy,
   DownloadSimple,
@@ -219,9 +220,11 @@ const previewValueForMetric = (metric?: string, 
threshold?: string | number | nu
 
 interface AlertsPageProps {
   domain?: AlertRuleDomain;
+  /** Rendered inside the cluster-alerts hub tab: no page header / outer 
padding. */
+  embedded?: boolean;
 }
 
-const AlertsPageContent = ({ domain = 'CLUSTER' }: AlertsPageProps) => {
+const AlertsPageContent = ({ domain = 'CLUSTER', embedded = false }: 
AlertsPageProps) => {
   const { t } = useLang();
   const { token } = theme.useToken();
   const [rules, setRules] = useState<AlertRule[]>([]);
@@ -996,63 +999,72 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
     notificationTemplateRef.current?.resizableTextArea?.textArea?.focus();
   };
 
+  const headerExtra = (
+    <Flex gap={16}>
+      <Flex align="center" gap={4}>
+        <span style={{ fontSize: 14, color: '#999' 
}}>{t('alerts.totalRules')}</span>
+        <span style={{ fontSize: 18, fontWeight: 600, color: '#3b82f6' 
}}>{totalRules}</span>
+      </Flex>
+      <Flex align="center" gap={4}>
+        <span style={{ fontSize: 14, color: '#999' 
}}>{t('alerts.enabledOnPage')}</span>
+        <span style={{ fontSize: 18, fontWeight: 600, color: '#14b8a6' 
}}>{enabledCount}</span>
+      </Flex>
+      <Flex align="center" gap={4}>
+        <span style={{ fontSize: 14, color: '#999' 
}}>{t('alerts.triggered24hOnPage')}</span>
+        <span style={{ fontSize: 18, fontWeight: 600, color: '#8b5cf6' 
}}>{triggered24h}</span>
+      </Flex>
+      <Button
+        icon={<DownloadSimple />}
+        disabled={isActionRunning}
+        loading={transferringRules}
+        onClick={() => void handleExportRules()}
+      >
+        {t('common.export')}
+      </Button>
+      <Button
+        icon={<UploadSimple />}
+        disabled={isActionRunning || transferringRules}
+        onClick={() => importInputRef.current?.click()}
+      >
+        {t('common.import')}
+      </Button>
+      <input
+        ref={importInputRef}
+        type="file"
+        accept="application/json,.json"
+        hidden
+        onChange={(event) => void handleImportRules(event)}
+      />
+      <Button type="primary" icon={<Plus />} disabled={isActionRunning} 
onClick={openCreateModal}>
+        {t('alerts.newRule')}
+      </Button>
+    </Flex>
+  );
+
+  // embedded:页头操作区经 portal 挂到枢纽页 Tab 栏右侧,与页签同一行,不单独占一行。
+  // 目标容器由枢纽页在同一次 commit 渲染,首帧 render 时还不存在;setState 用定时器 defer,
+  // 避开 react-hooks/set-state-in-effect(同 UserManagement 的 loading 手法)。
+  const [tabExtraTarget, setTabExtraTarget] = useState<HTMLElement | 
null>(null);
+  useEffect(() => {
+    if (!embedded) return undefined;
+    const timer = window.setTimeout(() => {
+      setTabExtraTarget(document.getElementById('cluster-alerts-tab-extra'));
+    }, 0);
+    return () => window.clearTimeout(timer);
+  }, [embedded]);
+
   return (
-    <div style={{ padding: 24 }}>
+    <div style={{ padding: embedded ? 0 : 24 }}>
       {/* ─── Header ─── */}
-      <PageHeader
-        title={t(domain === 'BUSINESS' ? 'alerts.businessTitle' : 
'alerts.title')}
-        subtitle={t(domain === 'BUSINESS' ? 'alerts.businessSubtitle' : 
'alerts.subtitle')}
-        extra={
-          <Flex gap={16}>
-            <Flex align="center" gap={4}>
-              <span style={{ fontSize: 14, color: '#999' 
}}>{t('alerts.totalRules')}</span>
-              <span style={{ fontSize: 18, fontWeight: 600, color: '#3b82f6' 
}}>{totalRules}</span>
-            </Flex>
-            <Flex align="center" gap={4}>
-              <span style={{ fontSize: 14, color: '#999' 
}}>{t('alerts.enabledOnPage')}</span>
-              <span style={{ fontSize: 18, fontWeight: 600, color: '#14b8a6' 
}}>
-                {enabledCount}
-              </span>
-            </Flex>
-            <Flex align="center" gap={4}>
-              <span style={{ fontSize: 14, color: '#999' 
}}>{t('alerts.triggered24hOnPage')}</span>
-              <span style={{ fontSize: 18, fontWeight: 600, color: '#8b5cf6' 
}}>
-                {triggered24h}
-              </span>
-            </Flex>
-            <Button
-              icon={<DownloadSimple />}
-              disabled={isActionRunning}
-              loading={transferringRules}
-              onClick={() => void handleExportRules()}
-            >
-              {t('common.export')}
-            </Button>
-            <Button
-              icon={<UploadSimple />}
-              disabled={isActionRunning || transferringRules}
-              onClick={() => importInputRef.current?.click()}
-            >
-              {t('common.import')}
-            </Button>
-            <input
-              ref={importInputRef}
-              type="file"
-              accept="application/json,.json"
-              hidden
-              onChange={(event) => void handleImportRules(event)}
-            />
-            <Button
-              type="primary"
-              icon={<Plus />}
-              disabled={isActionRunning}
-              onClick={openCreateModal}
-            >
-              {t('alerts.newRule')}
-            </Button>
-          </Flex>
-        }
-      />
+      {embedded ? (
+        tabExtraTarget && createPortal(headerExtra, tabExtraTarget)
+      ) : (
+        <PageHeader
+          title={t(domain === 'BUSINESS' ? 'alerts.businessTitle' : 
'alerts.title')}
+          subtitle={t(domain === 'BUSINESS' ? 'alerts.businessSubtitle' : 
'alerts.subtitle')}
+          extra={headerExtra}
+        />
+      )}
 
       {/* ─── Table ─── */}
       <Card styles={{ body: { padding: 0 } }}>
@@ -1651,8 +1663,8 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
 // Route changes reuse this page component. A domain owns its editor, metric 
requests,
 // test results and mutation state as well as its list; none may leak into the 
other domain.
 // The stable key preserves edits during navigation that stays within the same 
domain.
-const AlertsPage = ({ domain = 'CLUSTER' }: AlertsPageProps) => (
-  <AlertsPageContent key={domain} domain={domain} />
+const AlertsPage = ({ domain = 'CLUSTER', embedded = false }: AlertsPageProps) 
=> (
+  <AlertsPageContent key={domain} domain={domain} embedded={embedded} />
 );
 
 export default AlertsPage;
diff --git a/web/src/pages/ops/auditPresentation.ts 
b/web/src/pages/ops/auditPresentation.ts
index e2a71502a..1cbd5a75f 100644
--- a/web/src/pages/ops/auditPresentation.ts
+++ b/web/src/pages/ops/auditPresentation.ts
@@ -120,6 +120,26 @@ const operationPresentation: Record<string, 
AuditOperationPresentation> = {
   DELETE_ALERT_RULE: { label: 'Delete Alert Rule', color: 'volcano', category: 
'alerts' },
   CREATE_ALERT_SILENCE: { label: 'Create Alert Silence', color: 'blue', 
category: 'alerts' },
   DELETE_ALERT_SILENCE: { label: 'Delete Alert Silence', color: 'volcano', 
category: 'alerts' },
+  DELIVER_ALERT_NOTIFICATION: {
+    label: 'Deliver Alert Notification',
+    color: 'gold',
+    category: 'alerts',
+  },
+  RETRY_ALERT_NOTIFICATION: {
+    label: 'Retry Alert Notification',
+    color: 'gold',
+    category: 'alerts',
+  },
+  RETRY_ALERT_NOTIFICATION_MANUALLY: {
+    label: 'Retry Alert Notification Manually',
+    color: 'gold',
+    category: 'alerts',
+  },
+  FAIL_ALERT_NOTIFICATION: {
+    label: 'Fail Alert Notification',
+    color: 'volcano',
+    category: 'alerts',
+  },
   ACKNOWLEDGE_SYSTEM_ALERT: {
     label: 'Acknowledge System Alert',
     color: 'green',
@@ -135,6 +155,8 @@ const operationPresentation: Record<string, 
AuditOperationPresentation> = {
 
   UPDATE_GROUP_SETTINGS: { label: 'Update Group Settings', color: 'purple', 
category: 'metadata' },
 
+  SUMMARY_DASHBOARD: { label: 'View Summary Dashboard', color: 'default', 
category: 'other' },
+
   CREATE_INSTANCE: { label: 'Create Instance', color: 'blue', category: 
'instance' },
   UPDATE_INSTANCE: { label: 'Update Instance', color: 'cyan', category: 
'instance' },
   DELETE_INSTANCE: { label: 'Delete Instance', color: 'volcano', category: 
'instance' },
@@ -174,6 +196,7 @@ const resourcePresentation: Record<string, 
AuditPresentation> = {
   ALERT_RULE: { label: 'Alert Rule', color: 'gold' },
   ALERT_SILENCE: { label: 'Alert Silence', color: 'gold' },
   SYSTEM_ALERT: { label: 'System Alert', color: 'gold' },
+  ALERT_NOTIFICATION: { label: 'Alert Notification', color: 'gold' },
   K8S_CERTIFICATE: { label: 'K8s Certificate', color: 'lime' },
 };
 
diff --git a/web/src/pages/ops/clusterAlertsHub.tsx 
b/web/src/pages/ops/clusterAlertsHub.tsx
new file mode 100644
index 000000000..e05c4690a
--- /dev/null
+++ b/web/src/pages/ops/clusterAlertsHub.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 { useState } from 'react';
+import { Tabs } from 'antd';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import AlertsPage from './alerts';
+import ClusterEventsTab from './clusterEvents';
+import NotificationDeliveriesPage from './notificationDeliveries';
+import SystemAlertsPage from './systemAlerts';
+
+export type ClusterAlertsTabKey = 'rules' | 'events' | 'deliveries' | 
'clusterEvents';
+
+// 集群告警 / 告警事件 / 告警投递 / 集群事件 收进一个页面的四个 Tab(用户约定 2026-10-28),
+// 侧边栏不再各占一项。子页以 embedded 形态渲染(无页头、无外层 padding)。
+// destroyInactiveTabPane:切走即卸载,避免四个重列表同时挂着各自轮询与状态。
+const ClusterAlertsHub = ({ initialTab = 'rules' }: { initialTab?: 
ClusterAlertsTabKey }) => {
+  const { t } = useLang();
+  const [activeKey, setActiveKey] = useState<ClusterAlertsTabKey>(initialTab);
+  return (
+    <div style={{ padding: 24 }}>
+      <PageHeader title={t('nav.alertRules')} 
subtitle={t('clusterAlerts.subtitle')} />
+      <Tabs
+        activeKey={activeKey}
+        onChange={(key) => setActiveKey(key as ClusterAlertsTabKey)}
+        destroyInactiveTabPane
+        // 子页(告警规则 / 告警事件)把各自的页头操作区 portal 进这个容器,
+        // 与页签同一行右对齐,不单独占一行。
+        tabBarExtraContent={{
+          right: <div id="cluster-alerts-tab-extra" style={{ display: 
'inline-flex' }} />,
+        }}
+        items={[
+          {
+            key: 'rules',
+            label: t('nav.alertRules'),
+            children: <AlertsPage embedded />,
+          },
+          {
+            key: 'events',
+            label: t('nav.alertEvents'),
+            children: <SystemAlertsPage embedded />,
+          },
+          {
+            key: 'deliveries',
+            label: t('nav.alertDeliveries'),
+            children: <NotificationDeliveriesPage embedded />,
+          },
+          {
+            key: 'clusterEvents',
+            label: t('nav.clusterEvents'),
+            children: <ClusterEventsTab />,
+          },
+        ]}
+      />
+    </div>
+  );
+};
+
+export default ClusterAlertsHub;
diff --git a/web/src/pages/ops/clusterEvents.tsx 
b/web/src/pages/ops/clusterEvents.tsx
new file mode 100644
index 000000000..c5d165cf1
--- /dev/null
+++ b/web/src/pages/ops/clusterEvents.tsx
@@ -0,0 +1,200 @@
+/*
+ * 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, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { Button, Flex, Table, Tag, Tooltip, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { ArrowClockwise } from '@phosphor-icons/react';
+import type { AuditRecord } from '../../api/ops';
+import { listAuditRecords } from '../../api/ops';
+import InfoBanner from '../../components/InfoBanner';
+import { useLang } from '../../i18n/LangContext';
+import { formatUtcDateTime } from '../../utils/format';
+import { tableScrollX } from '../../utils/table';
+import { getAuditOperationPresentation, getAuditResultPresentation } from 
'./auditPresentation';
+
+// 集群事件 = 运维审计里已记录的控制面变更:Broker / 集群配置修改、Broker 重启、
+// Proxy 配置重载与地址增删。复用审计存储,不另建事件表。
+export const CLUSTER_EVENT_OPERATIONS = [
+  'UPDATE_BROKER_CONFIG',
+  'UPDATE_CLUSTER_CONFIG',
+  'RESTART_BROKER',
+  'RELOAD_PROXY_CONFIG',
+  'ADD_PROXY_ADDRESS',
+  'REMOVE_PROXY_ADDRESS',
+];
+
+const PAGE_SIZE_OPTIONS = [20, 50, 100];
+
+const ClusterEventsTab = () => {
+  const { t } = useLang();
+  const [items, setItems] = useState<AuditRecord[]>([]);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+  const [loading, setLoading] = useState(false);
+  const [refreshVersion, setRefreshVersion] = useState(0);
+
+  useEffect(() => {
+    let active = true;
+    // setLoading 用微任务 defer,规避 react-hooks/set-state-in-effect(同 
UserManagement 手法)。
+    Promise.resolve().then(() => {
+      if (active) setLoading(true);
+    });
+    listAuditRecords({ operationTypes: CLUSTER_EVENT_OPERATIONS, page, 
pageSize })
+      .then((result) => {
+        if (!active) return;
+        setItems(result.items);
+        setTotal(result.total);
+      })
+      .catch(() => {
+        if (active) message.error(t('clusterEvents.loadFailed'));
+      })
+      .finally(() => {
+        if (active) setLoading(false);
+      });
+    return () => {
+      active = false;
+    };
+  }, [page, pageSize, refreshVersion, t]);
+
+  const columns: ColumnsType<AuditRecord> = [
+    {
+      title: t('clusterEvents.time'),
+      dataIndex: 'timestamp',
+      width: 170,
+      align: 'right',
+      render: (value: string) => formatUtcDateTime(value),
+    },
+    {
+      title: t('clusterEvents.event'),
+      dataIndex: 'operationType',
+      width: 150,
+      align: 'center',
+      render: (value: string) => {
+        const presentation = getAuditOperationPresentation(value);
+        return (
+          <Tag color={presentation.color}>
+            {presentation.labelKey ? t(presentation.labelKey) : 
presentation.label}
+          </Tag>
+        );
+      },
+    },
+    {
+      title: t('clusterEvents.target'),
+      dataIndex: 'target',
+      width: 200,
+      ellipsis: true,
+      render: (value: string | null) => value || '-',
+    },
+    {
+      title: t('clusterEvents.cluster'),
+      dataIndex: 'clusterId',
+      width: 120,
+      ellipsis: true,
+      render: (value: string | null) => value || '-',
+    },
+    {
+      title: t('clusterEvents.operator'),
+      dataIndex: 'operator',
+      width: 120,
+      ellipsis: true,
+    },
+    {
+      title: t('clusterEvents.result'),
+      dataIndex: 'result',
+      width: 90,
+      align: 'center',
+      render: (value: string) => {
+        const presentation = getAuditResultPresentation(value);
+        return (
+          <Tag color={presentation.color}>
+            {presentation.labelKey ? t(presentation.labelKey) : 
presentation.label}
+          </Tag>
+        );
+      },
+    },
+    {
+      title: t('clusterEvents.detail'),
+      dataIndex: 'detail',
+      ellipsis: true,
+      render: (value: string) => (
+        <Tooltip title={value} placement="topLeft">
+          <span>{value || '-'}</span>
+        </Tooltip>
+      ),
+    },
+  ];
+
+  const refreshButton = (
+    <Button
+      icon={<ArrowClockwise size={16} />}
+      loading={loading}
+      onClick={() => setRefreshVersion((value) => value + 1)}
+    >
+      {t('common.refresh')}
+    </Button>
+  );
+
+  // 刷新跟其余 Tab 的操作区一样挂在 Tab 栏右侧,不单独占一行。目标容器首帧还不存在,
+  // setState 用定时器 defer 并规避 set-state-in-effect。
+  const [tabExtraTarget, setTabExtraTarget] = useState<HTMLElement | 
null>(null);
+  useEffect(() => {
+    const timer = window.setTimeout(() => {
+      setTabExtraTarget(document.getElementById('cluster-alerts-tab-extra'));
+    }, 0);
+    return () => window.clearTimeout(timer);
+  }, []);
+
+  return (
+    <Flex vertical gap={12}>
+      {tabExtraTarget && createPortal(refreshButton, tabExtraTarget)}
+      <InfoBanner
+        title={t('clusterEvents.bannerTitle')}
+        description={t('clusterEvents.bannerDescription')}
+      />
+      <Table
+        rowKey="id"
+        size="small"
+        loading={loading}
+        columns={columns}
+        dataSource={items}
+        tableLayout="fixed"
+        scroll={{ x: tableScrollX(columns) }}
+        pagination={{
+          current: page,
+          pageSize,
+          total,
+          showSizeChanger: true,
+          pageSizeOptions: PAGE_SIZE_OPTIONS.map(String),
+          showTotal: (count) => t('clusterEvents.total', { count }),
+          onChange: (nextPage, nextPageSize) => {
+            if (nextPageSize !== pageSize) {
+              setPage(1);
+              setPageSize(nextPageSize);
+            } else {
+              setPage(nextPage);
+            }
+          },
+        }}
+      />
+    </Flex>
+  );
+};
+
+export default ClusterEventsTab;
diff --git a/web/src/pages/ops/notificationDeliveries.tsx 
b/web/src/pages/ops/notificationDeliveries.tsx
index b5efb5e17..0afe04f59 100644
--- a/web/src/pages/ops/notificationDeliveries.tsx
+++ b/web/src/pages/ops/notificationDeliveries.tsx
@@ -55,7 +55,7 @@ const statusColors: 
Record<NotificationDeliveryRecord['status'], string> = {
   FAILED: 'error',
 };
 
-const NotificationDeliveriesPage = () => {
+const NotificationDeliveriesPage = ({ embedded = false }: { embedded?: boolean 
} = {}) => {
   const { t } = useLang();
   const [items, setItems] = useState<NotificationDeliveryRecord[]>([]);
   const [instances, setInstances] = useState<Instance[]>([]);
@@ -317,8 +317,10 @@ const NotificationDeliveriesPage = () => {
 
   return (
     <>
-      <div style={{ padding: 24 }}>
-        <PageHeader title={t('deliveries.title')} 
subtitle={t('deliveries.subtitle')} />
+      <div style={{ padding: embedded ? 0 : 24 }}>
+        {!embedded && (
+          <PageHeader title={t('deliveries.title')} 
subtitle={t('deliveries.subtitle')} />
+        )}
         <Card bodyStyle={{ padding: 20 }}>
           <Flex gap={12} wrap="wrap" style={{ marginBottom: 20 }}>
             <Button
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index 55aaa5077..1e0870d32 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -16,6 +16,7 @@
  */
 
 import { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
 import {
   Card,
   DatePicker,
@@ -144,7 +145,7 @@ const ALERT_EXPORT_COLUMNS: CsvColumn<SystemAlert>[] = [
   { header: 'Description', value: (alert) => alert.description },
 ];
 
-const SystemAlertsPage = () => {
+const SystemAlertsPage = ({ embedded = false }: { embedded?: boolean } = {}) 
=> {
   const { t } = useLang();
   const userId = useAuthStore((state) => state.userId);
   const admin = useAuthStore((state) => state.admin);
@@ -662,7 +663,7 @@ const SystemAlertsPage = () => {
     {
       title: t('sysAlerts.level'),
       key: 'level',
-      width: 110,
+      width: 100,
       render: (_: unknown, alert: SystemAlert) => {
         const normalizedLevel = normalizeAlertLevel(alert.level);
         const cfg = alertLevelConfig[normalizedLevel] ?? {
@@ -729,7 +730,7 @@ const SystemAlertsPage = () => {
     {
       title: t('sysAlerts.status'),
       key: 'transition',
-      width: 80,
+      width: 100,
       render: (_: unknown, alert: SystemAlert) =>
         alert.transition ? (
           <Tag style={{ marginInlineEnd: 0 }}>
@@ -746,7 +747,7 @@ const SystemAlertsPage = () => {
     {
       title: t('sysAlerts.description'),
       dataIndex: 'description',
-      width: 180,
+      width: 140,
       ellipsis: { showTitle: false },
       render: (description: string) => (
         <Tooltip title={description}>
@@ -759,7 +760,7 @@ const SystemAlertsPage = () => {
     {
       title: t('sysAlerts.instanceId'),
       key: 'instance',
-      width: 140,
+      width: 120,
       ellipsis: { showTitle: false },
       render: (_: unknown, alert: SystemAlert) => {
         const text = instanceTextOf(alert);
@@ -776,7 +777,7 @@ const SystemAlertsPage = () => {
     {
       title: t('sysAlerts.labels'),
       key: 'labels',
-      width: 80,
+      width: 72,
       align: 'center',
       render: (_: unknown, alert: SystemAlert) => {
         const entries = labelEntriesOf(alert);
@@ -806,7 +807,8 @@ const SystemAlertsPage = () => {
     {
       title: t('common.actions'),
       key: 'actions',
-      width: 190,
+      // 三个 link 按钮实测 218px,右对齐列宽小于它 + 16px padding 时会向左溢出压到时间列。
+      width: 240,
       align: 'right',
       render: (_: unknown, alert: SystemAlert) => (
         <Flex gap={4} justify="flex-end">
@@ -845,64 +847,97 @@ const SystemAlertsPage = () => {
       ),
     },
   ];
+  const headerExtra = (
+    <Flex gap={8} align="center">
+      {collectorStatus && (
+        // 与筛选控件同高同边框的状态片:Tag 的 success 配色只有 22px 高、绿底绿字,
+        // 夹在一排 32px 白底控件里既矮又跳。放在页头而不是筛选条,是因为 1600 视口下
+        // 筛选条放下 8 个控件后只剩 40px,容不下它。
+        <Flex
+          align="center"
+          gap={6}
+          style={{
+            height: 32,
+            padding: '0 12px',
+            border: `1px solid ${token.colorBorder}`,
+            borderRadius: token.borderRadius,
+            background: token.colorBgContainer,
+          }}
+        >
+          <Info size={14} color={token.colorTextSecondary} aria-hidden="true" 
/>
+          <Text style={{ fontSize: 14 }}>
+            {t('sysAlerts.collectorImplementations', {
+              cluster: collectorStatus.clusterCollectorCount,
+              business: collectorStatus.businessCollectorCount,
+            })}
+          </Text>
+        </Flex>
+      )}
+      <Button
+        icon={<DownloadSimple size={14} />}
+        onClick={() => void exportAlerts()}
+        loading={exporting}
+      >
+        {t('sysAlerts.exportCsv')}
+      </Button>
+      <Button 
onClick={openSilences}>{t('sysAlerts.maintenanceWindows')}</Button>
+      <Popconfirm
+        title={t('sysAlerts.clearAckedConfirm')}
+        description={t('sysAlerts.clearAckedConfirmDesc')}
+        onConfirm={() => void handleClearAcked()}
+        okText={t('common.confirm')}
+        cancelText={t('common.cancel')}
+      >
+        <Button
+          icon={<Trash size={14} />}
+          danger
+          disabled={!alerts.some((a) => a.acknowledged)}
+          loading={clearing}
+        >
+          {t('sysAlerts.clearAcked')}
+        </Button>
+      </Popconfirm>
+    </Flex>
+  );
+  const batchAckButton = (
+    <Button
+      disabled={selectedIds.length === 0}
+      loading={acknowledgingIds.size > 0}
+      onClick={() => void handleAckMany(selectedIds)}
+    >
+      {t('sysAlerts.batchAck', { count: selectedIds.length })}
+    </Button>
+  );
+
+  // embedded:页头操作区经 portal 挂到枢纽页 Tab 栏右侧,与页签同一行,不单独占一行。
+  // setState 用定时器 defer:目标容器首帧 render 时还不存在,且规避 set-state-in-effect。
+  const [tabExtraTarget, setTabExtraTarget] = useState<HTMLElement | 
null>(null);
+  useEffect(() => {
+    if (!embedded) return undefined;
+    const timer = window.setTimeout(() => {
+      setTabExtraTarget(document.getElementById('cluster-alerts-tab-extra'));
+    }, 0);
+    return () => window.clearTimeout(timer);
+  }, [embedded]);
+
   return (
-    <div style={{ padding: 24 }}>
-      <PageHeader
-        title={t('sysAlerts.title')}
-        subtitle={t('sysAlerts.subtitle', { n: loadFailed ? '-' : unackCount 
})}
-        extra={
-          <Flex gap={8} align="center">
-            {collectorStatus && (
-              // 与筛选控件同高同边框的状态片:Tag 的 success 配色只有 22px 高、绿底绿字,
-              // 夹在一排 32px 白底控件里既矮又跳。放在页头而不是筛选条,是因为 1600 视口下
-              // 筛选条放下 8 个控件后只剩 40px,容不下它。
-              <Flex
-                align="center"
-                gap={6}
-                style={{
-                  height: 32,
-                  padding: '0 12px',
-                  border: `1px solid ${token.colorBorder}`,
-                  borderRadius: token.borderRadius,
-                  background: token.colorBgContainer,
-                }}
-              >
-                <Info size={14} color={token.colorTextSecondary} 
aria-hidden="true" />
-                <Text style={{ fontSize: 14 }}>
-                  {t('sysAlerts.collectorImplementations', {
-                    cluster: collectorStatus.clusterCollectorCount,
-                    business: collectorStatus.businessCollectorCount,
-                  })}
-                </Text>
-              </Flex>
-            )}
-            <Button
-              icon={<DownloadSimple size={14} />}
-              onClick={() => void exportAlerts()}
-              loading={exporting}
-            >
-              {t('sysAlerts.exportCsv')}
-            </Button>
-            <Button 
onClick={openSilences}>{t('sysAlerts.maintenanceWindows')}</Button>
-            <Popconfirm
-              title={t('sysAlerts.clearAckedConfirm')}
-              description={t('sysAlerts.clearAckedConfirmDesc')}
-              onConfirm={() => void handleClearAcked()}
-              okText={t('common.confirm')}
-              cancelText={t('common.cancel')}
-            >
-              <Button
-                icon={<Trash size={14} />}
-                danger
-                disabled={!alerts.some((a) => a.acknowledged)}
-                loading={clearing}
-              >
-                {t('sysAlerts.clearAcked')}
-              </Button>
-            </Popconfirm>
-          </Flex>
-        }
-      />
+    <div style={{ padding: embedded ? 0 : 24 }}>
+      {embedded ? (
+        tabExtraTarget &&
+        createPortal(
+          <Flex gap={12} align="center">
+            {headerExtra}
+            {batchAckButton}
+          </Flex>,
+          tabExtraTarget,
+        )
+      ) : (
+        <PageHeader
+          title={t('sysAlerts.title')}
+          subtitle={t('sysAlerts.subtitle', { n: loadFailed ? '-' : unackCount 
})}
+          extra={headerExtra}
+        />
+      )}
 
       {loadFailed && (
         <Alert
@@ -1047,14 +1082,8 @@ const SystemAlertsPage = () => {
               <Text type="secondary" style={{ fontSize: 14 }}>
                 {t('sysAlerts.batchHint')}
               </Text>
-              <Button
-                size="small"
-                disabled={selectedIds.length === 0}
-                loading={acknowledgingIds.size > 0}
-                onClick={() => void handleAckMany(selectedIds)}
-              >
-                {t('sysAlerts.batchAck', { count: selectedIds.length })}
-              </Button>
+              {/* embedded 态批量确认跟其余操作一起进 Tab 栏右侧,不单独占一行。 */}
+              {!embedded && batchAckButton}
             </Flex>
             {/* A table, not a card per alert: one row each, secondary facts 
behind the expand
                 control, and a checkbox column so acknowledging a batch is one 
click instead of N. */}
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 334a320db..acfd30d95 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -27,11 +27,11 @@ import {
   Popconfirm,
   Select,
   Space,
-  Statistic,
   Switch,
   Table,
   Tag,
   Tooltip,
+  Typography,
   message,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -73,6 +73,8 @@ import { formatDelay, formatUtcDateTime } from 
'../../utils/format';
 import { tableScrollX } from '../../utils/table';
 import { useLang } from '../../i18n/LangContext';
 
+const { Text } = Typography;
+
 interface CreateFormValues {
   username: string;
   password: string;
@@ -114,15 +116,32 @@ const STUDIO_USER_EXPORT_COLUMNS: CsvColumn<StudioUser>[] 
= [
 
 const SessionStatusTags = ({ session }: { session: StudioUserSessionDetail }) 
=> {
   const { t } = useLang();
-  return (
-    <Space size={4} wrap>
-      <Tag color="processing">{t('userMgmt.tagActive')}</Tag>
-      {session.expiringSoon && <Tag 
color="gold">{t('userMgmt.tagExpiringSoon')}</Tag>}
-      {session.stale && <Tag color="orange">{t('userMgmt.tagStale')}</Tag>}
-    </Space>
-  );
+  // 本表列出的本就是活跃会话,「活跃」恒真;把它和「长时间未活跃」(空闲超阈值但仍未过期)
+  // 并排渲染会读成自相矛盾。故异常标记优先,「活跃」只作无异常时的兜底态。
+  if (session.expiringSoon || session.stale) {
+    return (
+      <Space size={4} wrap>
+        {session.expiringSoon && <Tag 
color="gold">{t('userMgmt.tagExpiringSoon')}</Tag>}
+        {session.stale && <Tag color="orange">{t('userMgmt.tagStale')}</Tag>}
+      </Space>
+    );
+  }
+  return <Tag color="processing">{t('userMgmt.tagActive')}</Tag>;
 };
 
+// 概览指标用「标签 + 数字」同一行呈现,不竖排成大数字卡:五个指标一行放下,
+// 首屏把可视预算让给用户表。
+const OverviewMetric = ({ label, value }: { label: string; value: number }) => 
(
+  <Flex align="baseline" gap={6}>
+    <Text type="secondary" style={{ fontSize: 14 }}>
+      {label}
+    </Text>
+    <Text strong style={{ fontSize: 16 }}>
+      {value}
+    </Text>
+  </Flex>
+);
+
 const UserManagementPage = () => {
   const { t } = useLang();
   const navigate = useNavigate();
@@ -475,43 +494,50 @@ const UserManagementPage = () => {
     setUserExporting(false);
   }, [admin, debouncedSearch, roleFilter, statusFilter, t]);
   const sessionDetailColumns: ColumnsType<StudioUserSessionDetail> = [
-    { title: t('userMgmt.sessionId'), dataIndex: 'id', width: 96 },
+    { title: t('userMgmt.sessionId'), dataIndex: 'id', width: 96, align: 
'right' },
     {
       title: t('common.status'),
       key: 'status',
-      width: 172,
+      width: 160,
+      align: 'center',
       render: (_, record) => <SessionStatusTags session={record} />,
     },
     {
       title: t('userMgmt.lastActive'),
       dataIndex: 'lastSeenAt',
       width: 160,
+      align: 'right',
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('userMgmt.idleFor'),
       dataIndex: 'idleSeconds',
-      width: 112,
+      width: 108,
+      align: 'right',
       render: durationText,
     },
     {
       title: t('userMgmt.expiresAt'),
       dataIndex: 'expiresAt',
-      width: 160,
+      width: 150,
+      align: 'right',
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('userMgmt.remainingValidity'),
       dataIndex: 'remainingSeconds',
-      width: 126,
+      // 「23小时59分钟55秒」实测 min-content 141px,声明宽小于它会把整表撑出弹窗 body。
+      width: 142,
+      align: 'right',
       render: durationText,
     },
     {
       title: t('userMgmt.createdAt'),
       dataIndex: 'gmtCreate',
-      width: 160,
+      width: 150,
+      align: 'right',
       ellipsis: true,
       render: dateTime,
     },
@@ -519,6 +545,7 @@ const UserManagementPage = () => {
       title: t('userMgmt.clientOrigin'),
       dataIndex: 'clientIp',
       width: 160,
+      align: 'right',
       ellipsis: true,
       render: (value: string | null | undefined, record) =>
         value ? (
@@ -532,7 +559,9 @@ const UserManagementPage = () => {
     {
       title: t('common.actions'),
       key: 'actions',
-      width: 96,
+      // 「注销会话」按钮实测 102px,声明宽小于它会把整表撑出弹窗 body。
+      width: 112,
+      align: 'right',
       render: (_, record) =>
         sessionModalUser && !mutatingUserIds.has(sessionModalUser.id) ? (
           <Popconfirm
@@ -549,18 +578,33 @@ const UserManagementPage = () => {
         ) : null,
     },
   ];
-  // Declared widths total 1116px, which stays inside the usable content width 
of a normal
-  // 1440px viewport (220px Sider plus page and Card padding), so the table 
does not show a
-  // horizontal scrollbar by default. Columns whose text can be longer than 
that truncate with
-  // the full value on hover instead of wrapping.
+  // 列宽合计 1266px。表格套在 Card 里,1600 视口下 Card body 内容宽 1330 再扣两侧 24px
+  // padding 只剩 1282,列宽总和超过它就会横向滚动(操作列曾声明 299px、九列合计 1326 时启用
+  // 开关被裁在右缘外)。时间标签实测 129px,small 表格左右各 8px padding,150 足够。
+  // 用户 ID 属次要事实,不在列表占列(会话弹窗与导出 CSV 仍提供);启用/禁用开关与状态同列,
+  // 角色开关与权限同列,控件跟着它所编辑的状态走,操作列只留四个动作按钮。
   const columns: ColumnsType<StudioUser> = [
-    // 用户名实测最长 50px(`reader1`)+ 32px padding = 82px;96 留有余量,省下的 24px 给操作列。
-    { title: t('userMgmt.username'), dataIndex: 'username', width: 96, 
ellipsis: true },
-    { title: t('userMgmt.userId'), dataIndex: 'id', width: 80 },
+    {
+      title: t('userMgmt.username'),
+      dataIndex: 'username',
+      width: 176,
+      ellipsis: true,
+      render: (value: string, record) => (
+        <Text
+          strong
+          style={{ cursor: 'pointer' }}
+          title={t('userMgmt.viewSessionsOf', { username: value })}
+          onClick={() => openSessionModal(record)}
+        >
+          {value}
+        </Text>
+      ),
+    },
     {
       title: t('userMgmt.role'),
       dataIndex: 'admin',
-      width: 88,
+      width: 110,
+      align: 'center',
       // The role is fixed at creation unless an administrator changes it 
here; readers keep
       // the read-only tag so the mutation surface stays admin-only like every 
other action.
       render: (value: boolean, record: StudioUser) =>
@@ -581,64 +625,69 @@ const UserManagementPage = () => {
     {
       title: t('common.status'),
       dataIndex: 'enabled',
-      width: 150,
+      width: 140,
+      align: 'center',
       render: (value: boolean, record) => (
-        <>
-          {value ? (
-            <Tag color="green">{t('common.enabled')}</Tag>
-          ) : (
-            <Tag color="default">{t('common.disabled')}</Tag>
-          )}
+        <Space size={4}>
+          <Switch
+            checked={value}
+            loading={mutatingUserIds.has(record.id)}
+            checkedChildren={t('userMgmt.enable')}
+            unCheckedChildren={t('userMgmt.disable')}
+            onChange={(enabled) => void setEnabled(record, enabled)}
+          />
           {record.passwordMustChange && (
             <Tooltip title={t('userMgmt.passwordMustChangeHelp')}>
-              <Tag color="orange">{t('userMgmt.passwordMustChange')}</Tag>
+              <Tag color="orange" style={{ marginInlineEnd: 0 }}>
+                {t('userMgmt.passwordMustChange')}
+              </Tag>
             </Tooltip>
           )}
-        </>
+        </Space>
       ),
     },
     {
       title: t('userMgmt.activeSessions'),
       dataIndex: 'activeSessionCount',
-      width: 80,
+      width: 90,
+      align: 'center',
       render: (value?: number) => {
         const count = value ?? 0;
-        return <Tag color={count > 0 ? 'processing' : 'default'}>{count}</Tag>;
+        return (
+          <Tag color={count > 0 ? 'processing' : 'default'} style={{ 
marginInlineEnd: 0 }}>
+            {count}
+          </Tag>
+        );
       },
     },
     {
       title: t('userMgmt.lastActive'),
       dataIndex: 'lastSessionSeenAt',
-      // The `YYYY-MM-DD HH:mm:ss` label measures 129px in this table 
(Chromium with this app's font
-      // stack), so the cell needs 129 + 32px of default-size padding = 161px. 
177 keeps that margin
-      // and frees 5px per time column for the action column below, whose 
buttons did not fit.
-      // The earlier 182 assumed a 149px label; the small-table equivalent 
used elsewhere is 156.
-      width: 177,
+      width: 150,
+      align: 'right',
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('userMgmt.nearestExpiry'),
       dataIndex: 'nearestSessionExpiresAt',
-      width: 177,
+      width: 150,
+      align: 'right',
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('userMgmt.createdAt'),
       dataIndex: 'gmtCreate',
-      width: 177,
+      width: 150,
+      align: 'right',
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('common.actions'),
       key: 'actions',
-      // 改密 / 会话 / 注销 三个小按钮实测各 66px + 启用禁用开关 57px + 3×4px 间距 = 267px,
-      // 加默认尺寸表格左右各 16px padding = 299px。原来声明 264px(内容盒 235px),开关整体
-      // 右溢 31.6px 冲出表格右缘(实测于本地 Chromium + Noto Sans CJK,1600 视口)。
-      // 多出的 35px 由用户名列(120 → 96,内容实测 50px)与三个时间列(各 -5px)收回。
-      width: 299,
+      width: 300,
       render: (_, record) => (
         <Space size={4}>
           <Button size="small" icon={<Key size={14} />} onClick={() => 
setPasswordTarget(record)}>
@@ -670,13 +719,6 @@ const UserManagementPage = () => {
               {t('userMgmt.revoke')}
             </Button>
           </Popconfirm>
-          <Switch
-            checked={record.enabled}
-            loading={mutatingUserIds.has(record.id)}
-            checkedChildren={t('userMgmt.enable')}
-            unCheckedChildren={t('userMgmt.disable')}
-            onChange={(enabled) => void setEnabled(record, enabled)}
-          />
           <Popconfirm
             title={t('userMgmt.deleteConfirm', { username: record.username })}
             description={t('userMgmt.deleteDescription')}
@@ -707,8 +749,26 @@ const UserManagementPage = () => {
         title={t('userMgmt.title')}
         subtitle={t('userMgmt.subtitle')}
         extra={
-          admin ? (
-            <Space>
+          <Space>
+            <Button
+              icon={<Key size={16} />}
+              disabled={!userId}
+              onClick={() =>
+                setPasswordTarget({
+                  id: userId ?? 0,
+                  username: '',
+                  admin: !!admin,
+                  enabled: true,
+                  activeSessionCount: 0,
+                  passwordChangedAt: '',
+                  gmtCreate: '',
+                  gmtModified: '',
+                })
+              }
+            >
+              {t('userMgmt.changeMyPassword')}
+            </Button>
+            {admin && (
               <Button
                 icon={<DownloadSimple size={16} />}
                 disabled={loading || total === 0}
@@ -717,11 +777,13 @@ const UserManagementPage = () => {
               >
                 {t('common.export')}
               </Button>
+            )}
+            {admin && (
               <Button type="primary" icon={<Plus size={16} />} 
onClick={openCreateUserModal}>
                 {t('userMgmt.createUser')}
               </Button>
-            </Space>
-          ) : undefined
+            )}
+          </Space>
         }
       />
       {!admin && (
@@ -730,26 +792,6 @@ const UserManagementPage = () => {
           description={t('userMgmt.notAdminDescription')}
         />
       )}
-      <Card title={t('userMgmt.myAccount')} style={{ marginBottom: 16 }}>
-        <Button
-          icon={<Key size={16} />}
-          disabled={!userId}
-          onClick={() =>
-            setPasswordTarget({
-              id: userId ?? 0,
-              username: '',
-              admin: !!admin,
-              enabled: true,
-              activeSessionCount: 0,
-              passwordChangedAt: '',
-              gmtCreate: '',
-              gmtModified: '',
-            })
-          }
-        >
-          {t('userMgmt.changeMyPassword')}
-        </Button>
-      </Card>
       {admin && sessionOverviewFailed && (
         <Alert
           type="error"
@@ -768,9 +810,49 @@ const UserManagementPage = () => {
         />
       )}
       {admin && sessionOverview && (
-        <Card
-          title={t('userMgmt.sessionOverview')}
-          extra={
+        <Card size="small" styles={{ body: { padding: '10px 16px' } }} 
style={{ marginBottom: 16 }}>
+          <Flex align="center" justify="space-between" gap={16} wrap>
+            <Flex align="center" gap={24} wrap>
+              <OverviewMetric
+                label={t('userMgmt.activeSessions')}
+                value={sessionOverview.activeSessionCount}
+              />
+              <OverviewMetric
+                label={t('userMgmt.activeUsers')}
+                value={sessionOverview.activeUserCount}
+              />
+              <OverviewMetric
+                label={t('userMgmt.expiringWithin', {
+                  minutes: sessionOverview.expiringSoonWindowMinutes,
+                })}
+                value={sessionOverview.expiringSoonSessionCount}
+              />
+              <OverviewMetric
+                label={t('userMgmt.idleMinutes', {
+                  minutes: sessionOverview.staleSessionThresholdMinutes,
+                })}
+                value={sessionOverview.staleSessionCount}
+              />
+              <OverviewMetric label={t('userMgmt.loginLockouts')} 
value={loginLockouts.length} />
+              {loginLockouts.length > 0 && (
+                <Flex gap={8} wrap>
+                  {loginLockouts.map((lockout) => (
+                    <Tooltip
+                      key={lockout.username}
+                      title={t('userMgmt.loginLockoutHelp')}
+                      placement="top"
+                    >
+                      <Tag color="red" style={{ marginInlineEnd: 0 }}>
+                        {lockout.username} ·{' '}
+                        {t('userMgmt.loginLockoutRemaining', {
+                          seconds: lockout.remainingSeconds,
+                        })}
+                      </Tag>
+                    </Tooltip>
+                  ))}
+                </Flex>
+              )}
+            </Flex>
             <Popconfirm
               title={t('userMgmt.revokeAllConfirmTitle')}
               description={t('userMgmt.revokeAllConfirmDescription')}
@@ -783,52 +865,12 @@ const UserManagementPage = () => {
                 {t('userMgmt.revokeAllSessions')}
               </Button>
             </Popconfirm>
-          }
-          style={{ marginBottom: 16 }}
-        >
-          <Flex gap={32} wrap>
-            <Statistic
-              title={t('userMgmt.activeSessions')}
-              value={sessionOverview.activeSessionCount}
-            />
-            <Statistic title={t('userMgmt.activeUsers')} 
value={sessionOverview.activeUserCount} />
-            <Statistic
-              title={t('userMgmt.expiringWithin', {
-                minutes: sessionOverview.expiringSoonWindowMinutes,
-              })}
-              value={sessionOverview.expiringSoonSessionCount}
-            />
-            <Statistic
-              title={t('userMgmt.idleMinutes', {
-                minutes: sessionOverview.staleSessionThresholdMinutes,
-              })}
-              value={sessionOverview.staleSessionCount}
-            />
-            <Statistic title={t('userMgmt.loginLockouts')} 
value={loginLockouts.length} />
           </Flex>
-          {loginLockouts.length > 0 && (
-            <Flex gap={8} wrap style={{ marginTop: 8 }}>
-              {loginLockouts.map((lockout) => (
-                <Tooltip
-                  key={lockout.username}
-                  title={t('userMgmt.loginLockoutHelp')}
-                  placement="top"
-                >
-                  <Tag color="red">
-                    {lockout.username} ·{' '}
-                    {t('userMgmt.loginLockoutRemaining', {
-                      seconds: lockout.remainingSeconds,
-                    })}
-                  </Tag>
-                </Tooltip>
-              ))}
-            </Flex>
-          )}
         </Card>
       )}
       {admin && (
         <Card>
-          <Flex gap={12} wrap style={{ marginBottom: 16 }}>
+          <Flex gap={8} wrap align="center" style={{ marginBottom: 16 }}>
             <Input.Search
               allowClear
               placeholder={t('userMgmt.searchPlaceholder')}
@@ -872,6 +914,7 @@ const UserManagementPage = () => {
           </Flex>
           <Table
             rowKey="id"
+            size="small"
             loading={loading}
             columns={columns}
             dataSource={users}
@@ -903,7 +946,7 @@ const UserManagementPage = () => {
             ? t('userMgmt.sessionsOf', { username: sessionModalUser.username })
             : t('userMgmt.userSessions')
         }
-        width="min(1040px, calc(100vw - 32px))"
+        width="min(1300px, calc(100vw - 32px))"
         open={sessionModalUser !== null}
         onCancel={closeSessionModal}
         destroyOnHidden
@@ -1004,6 +1047,7 @@ const UserManagementPage = () => {
             ) : (
               <Table
                 rowKey="id"
+                size="small"
                 loading={sessionDetailsLoading}
                 columns={sessionDetailColumns}
                 dataSource={sessionDetails}
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 741c23f41..ad8ab5065 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -727,6 +727,44 @@ describe('UserManagementPage', () => {
     expect(deleteStudioUser).not.toHaveBeenCalled();
   });
 
+  it('centers the role, status and session columns and right-aligns the time 
columns', async () => {
+    renderPage();
+    const row = (await screen.findByText('operator')).closest('tr') as 
HTMLElement;
+
+    // 列表列对齐规则(用户约定 2026-10-16):权限 / 状态 / 活跃会话是标签型短列居中,
+    // 三个时间列是读数型靠右,别改回默认左对齐。
+    const headers = Array.from(row.closest('table')!.querySelectorAll('thead 
th')).map((th) =>
+      th.textContent?.trim(),
+    );
+    const cells = within(row).getAllByRole('cell');
+    const alignOf = (title: string) => {
+      const index = headers.indexOf(title);
+      expect(index).toBeGreaterThan(-1);
+      return cells[index].style.textAlign;
+    };
+    expect(alignOf('权限')).toBe('center');
+    expect(alignOf('状态')).toBe('center');
+    expect(alignOf('活跃会话')).toBe('center');
+    expect(alignOf('最近活跃')).toBe('right');
+    expect(alignOf('最近过期')).toBe('right');
+    expect(alignOf('创建时间')).toBe('right');
+  });
+
+  it('keeps the user table within the 1600px viewport container', async () => {
+    renderPage();
+    await screen.findByText('operator');
+
+    // 操作列曾声明 299px、九列合计超出表格可用宽,表格横向滚动且启用开关被裁在右缘外。
+    // 表格套在 Card 里,1600 视口下可用宽 = Card body 1330 − 两侧 24px padding = 1282,
+    // 去掉用户 ID 列并把开关归位到状态列后合计 1266,默认不出横向滚动条。
+    const cols = Array.from(document.querySelectorAll('.ant-table-content 
colgroup col'));
+    const total = cols.reduce(
+      (sum, col) => sum + (parseInt((col as HTMLElement).style.width, 10) || 
0),
+      0,
+    );
+    expect(total).toBeLessThanOrEqual(1282);
+  });
+
   it('renders the page in English when the stored language preference is en', 
async () => {
     localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
     try {

Reply via email to