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 {