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 2df0a70e1 fix(studio): stop presenting unmeasured values as measured
(#5918)
2df0a70e1 is described below
commit 2df0a70e17335ce597ac213054e46e6d1ad7441f
Author: 烤化の初雪 <[email protected]>
AuthorDate: Tue Oct 27 21:38:48 2026 +0800
fix(studio): stop presenting unmeasured values as measured (#5918)
* fix(cluster): stop rendering a failed broker runtime-stats read as 0% and
0 TPS (#5918)
The cluster page sorted and rendered disk usage and TPS from whatever the
runtime
stats read returned, so a failed read showed as 0% and 0 TPS - a broker
that could
not be queried looked like an empty, healthy one. Track statsUnavailable and
render "unavailable" with direction-aware sorting, the way
BrokerCluster.tsx on
the studio page already did. The three bare sorters are the ones the page
had.
* fix(consumer): stop presenting an unmeasurable consume delay as 0 seconds
(#5920)
delaySeconds had no availability signal, so a group whose delay could not be
measured rendered as "0 seconds" in the list, the detail drawer, the CSV
export
and the diagnostics summary. Propagate the flag the backend already sends
(ConsumerGroupVO) through metadata.ts, consumerService.ts and
consumerGroupDiagnostics.ts, render "unavailable" at all five consumption
points,
and sort unmeasured rows to the bottom in both directions. While landing it
we
added the ascending-sort assertion the review asked for - without it the
MAX_SAFE_INTEGER branch of the local delaySortValue had no coverage - and
fixed a
line-wrap in the PR's own consumerService.test.ts that failed prettier.
* fix(consumer): keep unmeasured rows out of the top of a descending sort
(#5902)
The total-lag sorter treated an unmeasured value as the largest possible
number,
so a descending sort put the rows we know least about at the top. Add a
sortOrder
so unmeasured rows sink in both directions, following compareResourceCounts
on the
instance page and unmeasuredSortKey on the LiteTopic page. Scope note: this
covers
the total-lag column only. The PR as submitted also touched
consumerConnections.ts,
but trunk has since replaced onlineInstancesSortValue with
onlineInstancesCount
(#6916, where an unavailable count renders as 0 by design), and the
author's own
merge of trunk had already dropped that part - so it is not included here.
* fix(acl): apply the rule version filter the console sends (#5900)
The console has always sent aclVersion (all / 1.0 / 2.0) and the repository
predicate already existed, but the controller never declared the parameter
and the
service hard-coded null, so the version dropdown was a dead control. Thread
it
through the controller and the service and add the version comparison to the
Tencent in-memory filter chain. The 7-argument listRules overload stays:
AclListToolHandler still calls it.
* fix(alert): stop the notification template from rendering values in
scientific notation (#5889)
Thresholds and values went through String.valueOf, so a large threshold
reached
on-call as "1.0E+7". Route both render points through formatNumber.
* fix(auth): stamp the user and session audit columns in UTC (#5886)
The user and session tables left gmt_create / gmt_modified to database
defaults,
so the recorded instant depended on the server's timezone while the rest of
the
auth path is UTC - and #7311 had started surfacing gmt_create to the UI
through
StudioUserSessionDetailVO and StudioUserVO. Write both columns explicitly
from
AuthService.now(). Landing this needed the write points added since the PR
was
opened (#7311 and #7312) to be covered too, so all 13 write points now stamp
explicitly rather than half the table being UTC and half the database
default;
the conflicts in AuthService were resolved as a union, keeping #7312's
passwordMustChange and #7230's monotonic last_seen_at guard.
Batch integration of six related fixes by the same contributor; #5886,
#5889,
#5900, #5902 and #5920 are closed as merged into this one. Verified on the
combined branch: 4157 backend tests, 2016 frontend tests across 166 files,
the
production build and type check, license gate 8/8, and eslint with no new
warnings against the trunk baseline.
---
docs/api-spec.md | 3 +-
.../apache/rocketmq/studio/auth/AuthService.java | 48 ++++++--
.../studio/instance/acl/AclController.java | 3 +-
.../rocketmq/studio/instance/acl/AclService.java | 18 ++-
.../ops/alert/AlertNotificationTemplate.java | 14 ++-
.../studio/auth/AuthServiceDatabaseTest.java | 137 ++++++++++++++++++++-
.../studio/instance/acl/AclControllerTest.java | 23 +++-
.../studio/instance/acl/AclServiceTest.java | 32 +++++
.../ops/alert/AlertNotificationTemplateTest.java | 27 ++++
web/src/api/metadata.ts | 5 +
.../pages/cluster/__tests__/ClusterPage.test.tsx | 50 ++++++++
web/src/pages/cluster/index.tsx | 60 +++++++--
.../pages/instance/__tests__/ConsumerPage.test.tsx | 73 +++++++++++
web/src/pages/instance/consumer.tsx | 37 +++++-
web/src/services/consumerService.test.ts | 31 +++++
web/src/services/consumerService.ts | 8 +-
web/src/utils/consumerGroupDiagnostics.test.ts | 30 +++++
web/src/utils/consumerGroupDiagnostics.ts | 45 ++++---
web/src/utils/consumerLag.test.ts | 8 ++
web/src/utils/consumerLag.ts | 17 ++-
20 files changed, 612 insertions(+), 57 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 32c161128..8f124bf49 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1833,7 +1833,7 @@ POST /api/groups/reset-offset/preview
### 7.1 获取 ACL 规则列表
```
-GET
/api/acl/rules?principal={principal}&resource={resource}&scope={scope}&decision={decision}&instanceId={instanceId}&page={page}&pageSize={pageSize}
+GET
/api/acl/rules?principal={principal}&resource={resource}&scope={scope}&decision={decision}&aclVersion={aclVersion}&instanceId={instanceId}&page={page}&pageSize={pageSize}
```
**Query Parameters:**
@@ -1844,6 +1844,7 @@ GET
/api/acl/rules?principal={principal}&resource={resource}&scope={scope}&decis
| `resource` | `string` | 否 | 按资源过滤 |
| `scope` | `string` | 否 | 按作用域过滤 |
| `decision` | `string` | 否 | 按决策过滤 |
+| `aclVersion` | `string` | 否 | 按 ACL 版本过滤(`1.0` / `2.0`),省略时返回全部版本 |
| `instanceId` | `string` | 否 | 按所属实例过滤 |
| `page` | `number` | 否 | 页码,默认 `1` |
| `pageSize` | `number` | 否 | 每页条数,默认 `20` |
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 0d6c349bb..2a03ae3d0 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -190,10 +190,12 @@ public class AuthService {
public void logout(String authorization) {
tokenFromAuthorization(authorization).ifPresent(token -> {
if (databaseBacked()) {
+ LocalDateTime current = now();
sessionMapper.update(null, new
UpdateWrapper<RmqStudioSession>()
.eq("token_hash", tokenHash(token))
.isNull("revoked_at")
- .set("revoked_at", now()));
+ .set("revoked_at", current)
+ .set("gmt_modified", current));
} else {
activeTokens.remove(token);
}
@@ -350,6 +352,7 @@ public class AuthService {
wrapper.ne("user_id", Long.parseLong(currentUserId));
}
wrapper.set("revoked_at", current);
+ wrapper.set("gmt_modified", current);
return sessionMapper.update(null, wrapper);
}
@@ -371,7 +374,8 @@ public class AuthService {
sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
.eq("id", sessionId)
.isNull("revoked_at")
- .set("revoked_at", current));
+ .set("revoked_at", current)
+ .set("gmt_modified", current));
}
public RmqStudioUser createUser(String username, String password, boolean
admin) {
@@ -386,10 +390,16 @@ public class AuthService {
user.setPasswordHash(passwordHasher.hash(password));
user.setAdmin(admin);
user.setEnabled(true);
- user.setPasswordChangedAt(now());
+ LocalDateTime current = now();
+ user.setPasswordChangedAt(current);
// The initial password was chosen by the creator, not the owner: the
owner rotates it
// at first login.
user.setPasswordMustChange(true);
+ // An omitted gmt_create/gmt_modified falls back to the database
session's
+ // CURRENT_TIMESTAMP, which is not UTC on a server running in another
zone, while the
+ // API serializes every zone-less timestamp as UTC.
+ user.setGmtCreate(current);
+ user.setGmtModified(current);
try {
userMapper.insert(user);
} catch (DuplicateKeyException exception) {
@@ -418,7 +428,11 @@ public class AuthService {
throw new BusinessException(409, "The last enabled
administrator cannot be disabled");
}
}
- userMapper.updateById(userWithEnabled(user, enabled));
+ RmqStudioUser enableUpdate = userWithEnabled(user, enabled);
+ // updateById only writes non-null fields, so without this the
column's ON UPDATE
+ // CURRENT_TIMESTAMP - the database session's zone, not UTC - would be
the audit instant.
+ enableUpdate.setGmtModified(now());
+ userMapper.updateById(enableUpdate);
if (!enabled) {
revokeUserSessions(user.getId());
}
@@ -454,6 +468,10 @@ public class AuthService {
RmqStudioUser update = new RmqStudioUser();
update.setId(user.getId());
update.setAdmin(admin);
+ // Same reason as in setUserEnabled: updateById writes only non-null
fields, so the audit
+ // instant has to be stamped here or the column's ON UPDATE
CURRENT_TIMESTAMP - the
+ // database session's zone, not UTC - would be it.
+ update.setGmtModified(now());
userMapper.updateById(update);
revokeUserSessions(user.getId());
user.setAdmin(admin);
@@ -516,7 +534,8 @@ public class AuthService {
.set("password_changed_at", now())
// A password chosen by its owner needs no rotation; one
handed out by an
// administrator does, until the owner replaces it.
- .set("password_must_change", !requireCurrentPassword));
+ .set("password_must_change", !requireCurrentPassword)
+ .set("gmt_modified", now()));
revokeUserSessions(user.getId());
}
@@ -535,7 +554,8 @@ public class AuthService {
.isNotNull("last_seen_at")
.ge("expires_at", current)
.lt("last_seen_at",
current.minusMinutes(idleTimeoutMinutes))
- .set("revoked_at", current));
+ .set("revoked_at", current)
+ .set("gmt_modified", current));
}
sessionMapper.delete(new
QueryWrapper<RmqStudioSession>().lt("expires_at", current));
} else {
@@ -576,6 +596,8 @@ public class AuthService {
session.setExpiresAt(current.plusSeconds(tokenTtlSeconds));
session.setClientIp(truncate(clientIp, MAX_CLIENT_IP_LENGTH));
session.setUserAgent(truncate(userAgent, MAX_USER_AGENT_LENGTH));
+ session.setGmtCreate(current);
+ session.setGmtModified(current);
sessionMapper.insert(session);
return loginResponse(userInfo(user), token, tokenTtlSeconds);
}
@@ -616,7 +638,8 @@ public class AuthService {
&&
!session.getLastSeenAt().plusMinutes(idleTimeoutMinutes).isAfter(current)) {
sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
.eq("id", session.getId())
- .set("revoked_at", current));
+ .set("revoked_at", current)
+ .set("gmt_modified", current));
return Optional.empty();
}
if (session.getLastSeenAt() == null
@@ -625,7 +648,8 @@ public class AuthService {
.eq("id", session.getId())
// A delayed request must not overwrite activity a newer
request already recorded.
.and(activity ->
activity.isNull("last_seen_at").or().lt("last_seen_at", current))
- .set("last_seen_at", current));
+ .set("last_seen_at", current)
+ .set("gmt_modified", current));
}
return Optional.of(userInfo(user));
}
@@ -668,7 +692,10 @@ public class AuthService {
user.setPasswordHash(passwordHasher.hash(configuredUser.getPassword()));
user.setAdmin(configuredUser.isAdmin());
user.setEnabled(true);
- user.setPasswordChangedAt(now());
+ LocalDateTime current = now();
+ user.setPasswordChangedAt(current);
+ user.setGmtCreate(current);
+ user.setGmtModified(current);
try {
userMapper.insert(user);
} catch (DuplicateKeyException exception) {
@@ -704,7 +731,8 @@ public class AuthService {
.eq("user_id", userId)
.isNull("revoked_at")
.gt("expires_at", current)
- .set("revoked_at", current));
+ .set("revoked_at", current)
+ .set("gmt_modified", current));
}
private Set<Long> normalizeUserIds(Collection<Long> userIds) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
index 693927999..9451caaa2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
@@ -61,10 +61,11 @@ public class AclController {
@RequestParam(required = false) String resource,
@RequestParam(required = false) String scope,
@RequestParam(required = false) String decision,
+ @RequestParam(required = false) String aclVersion,
@RequestParam(required = false) String instanceId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize) {
- return Result.ok(aclService.listRules(principal, resource, scope,
decision,
+ return Result.ok(aclService.listRules(principal, resource, scope,
decision, aclVersion,
instanceId, page, pageSize));
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index 666350a90..1f3636532 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -101,6 +101,16 @@ public class AclService {
public PageResult<AclRuleVO> listRules(String principal, String resource,
String scope, String decision,
String instanceId, Integer page, Integer pageSize) {
+ return listRules(principal, resource, scope, decision, null,
instanceId, page, pageSize);
+ }
+
+ /**
+ * @param aclVersion restricts the page to one ACL version ({@code
1.0}/{@code 2.0}); the console
+ * has offered this filter all along, and the repository
predicate existed, but
+ * nothing carried the value from the request to it.
+ */
+ public PageResult<AclRuleVO> listRules(String principal, String resource,
String scope, String decision,
+ String aclVersion, String instanceId, Integer page, Integer
pageSize) {
int normalizedPage = requireValidPage(page);
int normalizedPageSize = requireValidPageSize(pageSize);
requireAcl2Supported(instanceId);
@@ -109,12 +119,14 @@ public class AclService {
.filter(rule -> containsIgnoreCase(rule.getResource(),
resource))
.filter(rule -> equalsIgnoreCase(rule.getScope(), scope))
.filter(rule -> equalsIgnoreCase(rule.getDecision(),
decision))
+ .filter(rule -> equalsIgnoreCase(rule.getAclVersion(),
aclVersion))
.toList();
return toUtcWire(paginateRules(filtered, normalizedPage,
normalizedPageSize));
}
- log.info("Listing ACL rules for principal={}, resource={}, scope={},
decision={}, page={}, pageSize={}",
- principal, resource, scope, decision, normalizedPage,
normalizedPageSize);
- return toUtcWire(aclRepository.findRulePage(principal, resource,
scope, decision, null,
+ log.info("Listing ACL rules for principal={}, resource={}, scope={},
decision={}, aclVersion={},"
+ + " page={}, pageSize={}",
+ principal, resource, scope, decision, aclVersion,
normalizedPage, normalizedPageSize);
+ return toUtcWire(aclRepository.findRulePage(principal, resource,
scope, decision, aclVersion,
normalizedPage, normalizedPageSize));
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java
index 1c75373cb..6017ee3e9 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java
@@ -51,7 +51,7 @@ final class AlertNotificationTemplate {
values.put("metric", text(rule == null ? null : rule.getMetric()));
values.put("instanceId", text(alert.getInstanceId()));
values.put("value", formattedValue(alert, rule));
- values.put("threshold", rule == null ? "" :
String.valueOf(rule.getThreshold()));
+ values.put("threshold", rule == null ? "" :
formatNumber(rule.getThreshold()));
values.put("thresholdUnit", text(rule == null ? null :
rule.getThresholdUnit()));
values.put("level", text(alert.getLevel()));
values.put("time", alert.getTime() == null ? "" :
alert.getTime().toString());
@@ -73,7 +73,17 @@ final class AlertNotificationTemplate {
?
BigDecimal.valueOf(currentValue).movePointRight(2).stripTrailingZeros().toPlainString()
: String.valueOf(currentValue);
}
- return String.valueOf(currentValue);
+ return formatNumber(currentValue);
+ }
+
+ /**
+ * Renders a measurement with {@code Double.toString} instead of the plain
decimal the rule
+ * dialog previews. {@code Double.toString} switches to computerized
scientific notation at 1e7,
+ * so a consumer-lag alert of 12,500,000 messages reached the operator as
"1.25E7" while the
+ * preview of the same template showed "12500000". Only a non-finite value
has no decimal form.
+ */
+ private static String formatNumber(double value) {
+ return Double.isFinite(value) ?
BigDecimal.valueOf(value).toPlainString() : String.valueOf(value);
}
private static String formatLabels(Map<String, String> labels) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 1cb77c6d1..3c8e1d7d5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -56,6 +56,8 @@ import static org.mockito.Mockito.when;
class AuthServiceDatabaseTest {
+ private static final LocalDateTime UTC_CLOCK_INSTANT =
LocalDateTime.of(2026, 8, 13, 0, 0);
+
private AuthService authService;
private SettingsRepository settingsRepository;
private RmqStudioUserMapper userMapper;
@@ -293,6 +295,54 @@ class AuthServiceDatabaseTest {
assertThat(captor.getValue().getUserAgent()).isNull();
}
+ @Test
+ void createdUserCarriesTheUtcAuditInstantsTheApiSerializesTest() {
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+
+ authService.createUser("operator", "password-1", false);
+
+ org.mockito.ArgumentCaptor<RmqStudioUser> captor =
+ org.mockito.ArgumentCaptor.forClass(RmqStudioUser.class);
+ verify(userMapper).insert(captor.capture());
+ // rmq_studio_user.gmt_create/gmt_modified otherwise fall back to the
database session's
+ // CURRENT_TIMESTAMP, while the API serializes every zone-less
timestamp as UTC.
+
assertThat(captor.getValue().getGmtCreate()).isEqualTo(UTC_CLOCK_INSTANT);
+
assertThat(captor.getValue().getGmtModified()).isEqualTo(UTC_CLOCK_INSTANT);
+ }
+
+ @Test
+ void createdSessionCarriesTheUtcAuditInstantsTheApiSerializesTest() {
+ RmqStudioUser user = user(1L, "operator", true, true, "password-1");
+ when(userMapper.selectCount(isNull())).thenReturn(1L);
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(user);
+ LoginDTO request = new LoginDTO();
+ request.setUsername("operator");
+ request.setPassword("password-1");
+
+ authService.login(request);
+
+ org.mockito.ArgumentCaptor<RmqStudioSession> captor =
+ org.mockito.ArgumentCaptor.forClass(RmqStudioSession.class);
+ verify(sessionMapper).insert(captor.capture());
+
assertThat(captor.getValue().getGmtCreate()).isEqualTo(UTC_CLOCK_INSTANT);
+
assertThat(captor.getValue().getGmtModified()).isEqualTo(UTC_CLOCK_INSTANT);
+ }
+
+ @Test
+ void passwordChangeStampsTheUtcModifiedInstantTest() {
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ when(userMapper.selectById(1L)).thenReturn(user);
+
+ authService.changePassword(1L, "password-1", "password-2", true);
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioUser>> updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(userMapper).update(isNull(), updateCaptor.capture());
+
assertThat(updateCaptor.getValue().getSqlSet()).contains("gmt_modified");
+ assertThat(updateCaptor.getValue().getParamNameValuePairs())
+ .containsValue(UTC_CLOCK_INSTANT);
+ }
+
@Test
void passwordChangeRevokesExistingSessions() {
RmqStudioUser user = user(1L, "operator", false, true, "password-1");
@@ -708,7 +758,92 @@ class AuthServiceDatabaseTest {
assertThat(authService.isAuthenticated("Bearer token-1")).isTrue();
- verify(sessionMapper).update(isNull(), any(Wrapper.class));
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+
assertThat(updateCaptor.getValue().getSqlSet()).contains("last_seen_at",
"gmt_modified");
+
assertThat(updateCaptor.getValue().getParamNameValuePairs()).containsValue(UTC_CLOCK_INSTANT);
+ }
+
+ @Test
+ void userAndSessionUpdatesStampTheUtcAuditInstantTest() {
+ RmqStudioUser target = user(1L, "admin-one", true, true, "password-1");
+ RmqStudioUser other = user(2L, "admin-two", true, true, "password-1");
+ when(userMapper.selectById(1L)).thenReturn(target);
+
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(target,
other));
+
+ authService.setUserEnabled(1L, false);
+
+ // rmq_studio_user.gmt_modified is ON UPDATE CURRENT_TIMESTAMP, which
is the database
+ // session's zone; the API serializes every zone-less timestamp as
UTC, so the app has to
+ // write the instant itself.
+ org.mockito.ArgumentCaptor<RmqStudioUser> updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(RmqStudioUser.class);
+ verify(userMapper).updateById(updateCaptor.capture());
+
assertThat(updateCaptor.getValue().getGmtModified()).isEqualTo(UTC_CLOCK_INSTANT);
+ }
+
+ @Test
+ void revokingSessionsStampsTheUtcModifiedInstantTest() {
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ when(userMapper.selectById(1L)).thenReturn(user);
+ when(sessionMapper.update(isNull(), any(Wrapper.class))).thenReturn(2);
+
+ authService.revokeSessionsForUser(1L);
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at",
"gmt_modified");
+
assertThat(updateCaptor.getValue().getParamNameValuePairs()).containsValue(UTC_CLOCK_INSTANT);
+ }
+
+ @Test
+ void loggingOutStampsTheUtcModifiedInstantTest() {
+ authService.logout("Bearer token-1");
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at",
"gmt_modified");
+
assertThat(updateCaptor.getValue().getParamNameValuePairs()).containsValue(UTC_CLOCK_INSTANT);
+ }
+
+ @Test
+ void revokingEverySessionStampsTheUtcModifiedInstantTest() {
+ authService.revokeAllSessions();
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at",
"gmt_modified");
+ }
+
+ @Test
+ void revokingASessionByIdStampsTheUtcModifiedInstantTest() {
+ RmqStudioSession session = activeSession(19L, 7L,
+ LocalDateTime.parse("2026-08-13T00:00:00"));
+ when(sessionMapper.selectById(19L)).thenReturn(session);
+
+ authService.revokeSessionById(19L);
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at",
"gmt_modified");
+ }
+
+ @Test
+ void grantingTheAdministratorRoleStampsTheUtcAuditInstantTest() {
+ RmqStudioUser operator = user(2L, "operator", false, true,
"password-1");
+ when(userMapper.selectById(2L)).thenReturn(operator);
+
+ authService.setUserAdmin(2L, true);
+
+ org.mockito.ArgumentCaptor<RmqStudioUser> updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(RmqStudioUser.class);
+ verify(userMapper).updateById(updateCaptor.capture());
+
assertThat(updateCaptor.getValue().getGmtModified()).isEqualTo(UTC_CLOCK_INSTANT);
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
index 388d6e7de..b4001fc36 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
@@ -109,7 +109,7 @@ class AclControllerTest extends WebMvcAuthTestSupport {
rule.setId(1L);
rule.setGmtCreate(LocalDateTime.of(2026, 1, 1, 0, 0));
- when(aclService.listRules(isNull(), isNull(), isNull(), isNull(),
isNull(), eq(1), eq(20)))
+ when(aclService.listRules(isNull(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1), eq(20)))
.thenReturn(PageResult.of(java.util.List.of(rule), 1, 1, 20));
mockMvc.perform(get("/api/acl/rules"))
@@ -127,7 +127,7 @@ class AclControllerTest extends WebMvcAuthTestSupport {
@Test
void listRulesShouldPassQueryParams() throws Exception {
when(aclService.listRules(eq("user1"), eq("topic-a"), eq("namespace"),
eq("DENY"),
- isNull(), eq(3), eq(5))).thenReturn(PageResult.empty(3, 5));
+ isNull(), isNull(), eq(3),
eq(5))).thenReturn(PageResult.empty(3, 5));
mockMvc.perform(get("/api/acl/rules")
.param("principal", "user1")
@@ -142,12 +142,25 @@ class AclControllerTest extends WebMvcAuthTestSupport {
.andExpect(jsonPath("$.data.size").value(5));
verify(aclService).listRules(eq("user1"), eq("topic-a"),
eq("namespace"), eq("DENY"),
- isNull(), eq(3), eq(5));
+ isNull(), isNull(), eq(3), eq(5));
+ }
+
+ @Test
+ void listRulesShouldApplyTheAclVersionFilterTest() throws Exception {
+ when(aclService.listRules(isNull(), isNull(), isNull(), isNull(),
eq("1.0"),
+ isNull(), eq(1), eq(20))).thenReturn(PageResult.empty(1, 20));
+
+ mockMvc.perform(get("/api/acl/rules").param("aclVersion", "1.0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ verify(aclService).listRules(isNull(), isNull(), isNull(), isNull(),
eq("1.0"),
+ isNull(), eq(1), eq(20));
}
@Test
void listRulesShouldRejectPageSizeAboveTheInventoryLimit() throws
Exception {
- when(aclService.listRules(isNull(), isNull(), isNull(), isNull(),
isNull(),
+ when(aclService.listRules(isNull(), isNull(), isNull(), isNull(),
isNull(), isNull(),
eq(1), eq(101))).thenThrow(new BusinessException(400,
"page must be >= 1 and pageSize must be between 1 and 100"));
@@ -159,7 +172,7 @@ class AclControllerTest extends WebMvcAuthTestSupport {
.andExpect(jsonPath("$.message")
.value("page must be >= 1 and pageSize must be between
1 and 100"));
- verify(aclService).listRules(isNull(), isNull(), isNull(), isNull(),
isNull(),
+ verify(aclService).listRules(isNull(), isNull(), isNull(), isNull(),
isNull(), isNull(),
eq(1), eq(101));
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index 0547fa804..71915a5aa 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -129,6 +129,38 @@ class AclServiceTest {
verify(aclRepository).findRulePage("user1", "topic", "cluster",
"ALLOW", null, 2, 5);
}
+ @Test
+ void listRulesShouldForwardTheAclVersionFilterToTheRepositoryTest() {
+ when(aclRepository.findRulePage(null, null, null, null, "1.0", 1, 20))
+ .thenReturn(PageResult.of(List.of(
+
AclRuleVO.builder().principal("user1").aclVersion("1.0").build()), 1, 1, 20));
+
+ PageResult<AclRuleVO> result = aclService.listRules(null, null, null,
null, "1.0",
+ null, 1, 20);
+
+ assertThat(result.getItems()).hasSize(1);
+ verify(aclRepository).findRulePage(null, null, null, null, "1.0", 1,
20);
+ }
+
+ @Test
+ void listRulesShouldFilterTencentRulesByAclVersionTest() {
+ InstanceVO instance = InstanceVO.builder()
+ .name("tencent-instance")
+ .vendor(InstanceVendor.TENCENT)
+ .type(InstanceType.CLOUD)
+ .build();
+
when(instanceResolver.findByIdentifier("tencent-instance")).thenReturn(Optional.of(instance));
+ when(tencentAclService.listRules("tencent-instance",
null)).thenReturn(List.of(
+
AclRuleVO.builder().principal("role-legacy").aclVersion("1.0").build(),
+
AclRuleVO.builder().principal("role-acl2").aclVersion("2.0").build()));
+
+ PageResult<AclRuleVO> result = aclService.listRules(null, null, null,
null, "2.0",
+ "tencent-instance", 1, 20);
+
+
assertThat(result.getItems()).extracting(AclRuleVO::getPrincipal).containsExactly("role-acl2");
+ assertThat(result.getTotal()).isEqualTo(1);
+ }
+
@Test
void capabilitiesShouldDescribeApacheRemoteReadSupport() {
InstanceVO instance = InstanceVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java
index 032fe9aad..e3730ca11 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java
@@ -32,6 +32,33 @@ class AlertNotificationTemplateTest {
+ "brokerAddr=127.0.0.1:10911,
brokerName=broker-a|${missing}");
}
+ @Test
+ void rendersLargeValuesAndThresholdsWithoutScientificNotationTest() {
+ AlertRuleVO rule = AlertRuleVO.builder().name("Consumer
lag").metric("consumer.lag.total")
+ .threshold(12000000).thresholdUnit("messages").build();
+ SystemAlertVO alert =
SystemAlertVO.builder().level(AlertLevel.warning).title("Consumer lag")
+ .description("FIRING").transition("FIRING").instanceId("local")
+ .currentValue(12500000.0).time(LocalDateTime.of(2026, 8, 23,
12, 0))
+ .labels(Map.of()).build();
+
+ String rendered =
AlertNotificationTemplate.render("${value}/${threshold}${thresholdUnit}",
alert, rule);
+
+ // Double.toString would deliver the same alert as
"1.25E7/1.2E7messages".
+ assertThat(rendered).isEqualTo("12500000/12000000messages");
+ }
+
+ @Test
+ void rendersNonFiniteValuesWithoutAPlainDecimalFormTest() {
+ AlertRuleVO rule = AlertRuleVO.builder().name("Consumer
lag").metric("consumer.lag.total")
+ .threshold(100).thresholdUnit("messages").build();
+ SystemAlertVO alert =
SystemAlertVO.builder().level(AlertLevel.warning).title("Consumer lag")
+ .description("FIRING").transition("FIRING").instanceId("local")
+
.currentValue(Double.POSITIVE_INFINITY).time(LocalDateTime.of(2026, 8, 23, 12,
0))
+ .labels(Map.of()).build();
+
+ assertThat(AlertNotificationTemplate.render("${value}", alert,
rule)).isEqualTo("Infinity");
+ }
+
@Test
void rendersPercentageValuesForPaddedStoredMetricsTest() {
AlertRuleVO rule = AlertRuleVO.builder().name("Disk
threshold").metric(" broker.disk.usage_ratio ")
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index 02361644c..32e87af0c 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -111,6 +111,11 @@ export interface ConsumerGroup {
gmtCreate: string;
gmtModified: string;
delaySeconds: number;
+ /**
+ * Whether the broker consume stats carried a consumed-message timestamp.
When false,
+ * `delaySeconds` is the provider's placeholder zero, not a measured delay.
+ */
+ consumptionTimestampAvailable?: boolean;
instances: ConsumerInstance[];
}
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 286a90af6..3a9bb775a 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -404,6 +404,56 @@ describe('Cluster page', () => {
expect(clusterServiceMocks.restartProxy).toHaveBeenCalledTimes(1);
});
+ it('reports a failed broker runtime-stats read as unavailable instead of 0%
and 0 TPS', async () => {
+ const cluster = buildCluster();
+ clusterServiceMocks.listRegistryClusters.mockResolvedValue([
+ {
+ ...cluster,
+ brokers: [
+ {
+ ...cluster.brokers[0],
+ // The shape RocketMQClusterProvider leaves behind when the stats
read fails.
+ runtimeStatsAvailable: false,
+ diskUsage: 0,
+ tpsIn: 0,
+ tpsOut: 0,
+ },
+ // A second, measured broker: without one the order assertions below
could never fail.
+ {
+ ...cluster.brokers[1],
+ runtimeStatsAvailable: true,
+ diskUsage: 42,
+ tpsIn: 950_000,
+ tpsOut: 12_345,
+ },
+ ],
+ },
+ ]);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<ClusterPage />);
+
+ const brokerRow = await screen.findByRole('row', { name:
/10\.101\.2\.11:10911/ });
+ expect(within(brokerRow).queryByText('0%')).not.toBeInTheDocument();
+ expect(within(brokerRow).getAllByText('不可用')).toHaveLength(3);
+ const measuredRow = screen.getByRole('row', { name: /10\.101\.2\.12:10911/
});
+ expect(within(measuredRow).getByText('950,000')).toBeInTheDocument();
+ expect(within(measuredRow).queryByText('不可用')).not.toBeInTheDocument();
+
+ const tpsInHeader = screen.getAllByText('TPS In')[0];
+ const brokerRowOrder = () =>
+ Array.from(document.querySelectorAll('tbody tr')).map((row) =>
row.textContent ?? '');
+ const unmeasuredIndex = () =>
+ brokerRowOrder().findIndex((text) => text.includes('10.101.2.11:10911'));
+ const measuredIndex = () =>
+ brokerRowOrder().findIndex((text) => text.includes('10.101.2.12:10911'));
+
+ // Neither direction may promote the unmeasured broker above the measured
one.
+ await user.click(tpsInHeader);
+ expect(measuredIndex()).toBeLessThan(unmeasuredIndex());
+ await user.click(tpsInHeader);
+ expect(measuredIndex()).toBeLessThan(unmeasuredIndex());
+ });
+
it('centers the broker status column', async () => {
renderWithProviders(<ClusterPage />);
const brokerRow = await screen.findByRole('row', { name:
/10\.101\.2\.11:10911/ });
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index babd276db..e2514f82e 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -42,6 +42,7 @@ import {
Col,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
+import type { SortOrder } from 'antd/es/table/interface';
import {
ReloadOutlined,
SettingOutlined,
@@ -1265,6 +1266,28 @@ const ClusterPage = () => {
nsClusterName: string;
cluster: ClusterInfo;
};
+ // A broker whose runtime stats read failed carries diskUsage/tpsIn/tpsOut
as Java zero
+ // defaults plus runtimeStatsAvailable=false
(BrokerVO/RocketMQClusterProvider). Rendering the
+ // zero claims an empty disk and no traffic, so the cell and the order
have to treat it as
+ // unknown - the same way the studio cluster page does.
+ const statsUnavailable = (broker: BrokerWithCluster) =>
broker.runtimeStatsAvailable === false;
+ const compareBrokerStats = (
+ left: BrokerWithCluster,
+ right: BrokerWithCluster,
+ field: 'diskUsage' | 'tpsIn' | 'tpsOut',
+ sortOrder?: SortOrder,
+ ): number => {
+ const leftUnavailable = statsUnavailable(left);
+ const rightUnavailable = statsUnavailable(right);
+ if (leftUnavailable || rightUnavailable) {
+ if (leftUnavailable === rightUnavailable) return 0;
+ // Ant Design reverses the comparator for a descending column, so
invert this branch to
+ // keep unavailable brokers after measured ones in either order.
+ const unavailableAfterAvailable = sortOrder === 'descend' ? -1 : 1;
+ return leftUnavailable ? unavailableAfterAvailable :
-unavailableAfterAvailable;
+ }
+ return left[field] - right[field];
+ };
const brokerSearchText = searchText(brokerSearch);
const allBrokers: BrokerWithCluster[] = registryClusters.flatMap((c) =>
@@ -1342,14 +1365,17 @@ const ClusterPage = () => {
dataIndex: 'diskUsage',
key: 'diskUsage',
width: 150,
- sorter: (a, b) => a.diskUsage - b.diskUsage,
- render: (v: number) => (
- <Progress
- percent={roundPercent(v)}
- size="small"
- strokeColor={v > 85 ? '#ff4d4f' : v > 70 ? '#faad14' : '#1677ff'}
- />
- ),
+ sorter: (a, b, sortOrder) => compareBrokerStats(a, b, 'diskUsage',
sortOrder),
+ render: (v: number, record: BrokerWithCluster) =>
+ statsUnavailable(record) ? (
+ <Text type="secondary">{t('common.unavailable')}</Text>
+ ) : (
+ <Progress
+ percent={roundPercent(v)}
+ size="small"
+ strokeColor={v > 85 ? '#ff4d4f' : v > 70 ? '#faad14' : '#1677ff'}
+ />
+ ),
},
{
title: t('common.address'),
@@ -1366,8 +1392,13 @@ const ClusterPage = () => {
key: 'tpsIn',
width: 90,
align: 'right',
- sorter: (a, b) => a.tpsIn - b.tpsIn,
- render: (v: number) => v.toLocaleString(),
+ sorter: (a, b, sortOrder) => compareBrokerStats(a, b, 'tpsIn',
sortOrder),
+ render: (v: number, record: BrokerWithCluster) =>
+ statsUnavailable(record) ? (
+ <Text type="secondary">{t('common.unavailable')}</Text>
+ ) : (
+ v.toLocaleString()
+ ),
},
{
title: 'TPS Out',
@@ -1375,8 +1406,13 @@ const ClusterPage = () => {
key: 'tpsOut',
width: 90,
align: 'right',
- sorter: (a, b) => a.tpsOut - b.tpsOut,
- render: (v: number) => v.toLocaleString(),
+ sorter: (a, b, sortOrder) => compareBrokerStats(a, b, 'tpsOut',
sortOrder),
+ render: (v: number, record: BrokerWithCluster) =>
+ statsUnavailable(record) ? (
+ <Text type="secondary">{t('common.unavailable')}</Text>
+ ) : (
+ v.toLocaleString()
+ ),
},
{
title: t('common.actions'),
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 89f671747..cb77cabaf 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -2663,6 +2663,49 @@ describe('Consumer page', () => {
expect(within(dialog).getByText('不可用')).toBeInTheDocument();
});
+ it('reports a group without a consumed-message timestamp as an unknown
delay', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+ groupPage([
+ {
+ ...group,
+ name: 'unknown-delay-cg',
+ delaySeconds: 0,
+ consumptionTimestampAvailable: false,
+ },
+ {
+ ...group,
+ name: 'known-delay-cg',
+ delaySeconds: 120,
+ consumptionTimestampAvailable: true,
+ },
+ ]),
+ );
+ renderWithProviders(<ConsumerPage />);
+
+ const unknownRow = await screen.findByRole('row', { name:
/unknown-delay-cg/ });
+ // The provider's placeholder zero must not be presented as "caught up".
+ expect(within(unknownRow).getByText('不可用')).toBeInTheDocument();
+ expect(within(unknownRow).queryByText('0秒')).not.toBeInTheDocument();
+ await user.click(within(unknownRow).getByRole('button', { name: /详\s*情/
}));
+ const dialog = await screen.findByRole('dialog', { name:
/unknown-delay-cg/ });
+ expect(within(dialog).getByText('不可用')).toBeInTheDocument();
+
+ // ... and neither sort direction may promote it.
+ const [delayHeader] = screen.getAllByText('消费延迟');
+ const rowTexts = () =>
+ Array.from(document.querySelectorAll('tbody tr')).map((row) =>
row.textContent ?? '');
+ const unknownIndex = () => rowTexts().findIndex((text) =>
text.includes('unknown-delay-cg'));
+ const knownIndex = () => rowTexts().findIndex((text) =>
/\bknown-delay-cg\b/.test(text));
+
+ // Ascending: pins the MAX_SAFE_INTEGER sentinel that keeps an unmeasured
row last.
+ await user.click(delayHeader);
+ await waitFor(() => expect(unknownIndex()).toBeGreaterThan(knownIndex()));
+ // Descending: antd negates the comparator, so the sentinel has to flip
with the direction.
+ await user.click(delayHeader);
+ await waitFor(() => expect(unknownIndex()).toBeGreaterThan(knownIndex()));
+ });
+
it('sorts groups with an unknown lag after known backlogs in lag order',
async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
@@ -2692,6 +2735,36 @@ describe('Consumer page', () => {
});
});
+ it('keeps an unknown lag after known backlogs in the descending order too',
async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+ groupPage([
+ { ...group, name: 'unknown-lag-cg', totalLag: -1 },
+ { ...group, name: 'known-lag-cg', totalLag: 15000 },
+ ]),
+ );
+ renderWithProviders(<ConsumerPage />);
+ await screen.findByRole('row', { name: /unknown-lag-cg/ });
+
+ const [lagHeader] = screen.getAllByText('总堆积量');
+ // Ascending, then descending: the row with no measurement must not lead
either list.
+ await user.click(lagHeader);
+ await user.click(lagHeader);
+ await waitFor(() => {
+ const order = Array.from(document.querySelectorAll('tbody tr'))
+ .map((row) => row.textContent ?? '')
+ .map((text) =>
+ text.includes('unknown-lag-cg')
+ ? 'unknown'
+ : /\bknown-lag-cg\b/.test(text)
+ ? 'known'
+ : '',
+ )
+ .filter(Boolean);
+ expect(order).toEqual(['known', 'unknown']);
+ });
+ });
+
it('renders an unavailable connection inventory as zero and sorts it as
zero', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 1a02b099a..35925203d 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
+import type { SortOrder } from 'antd/es/table/interface';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
@@ -121,6 +122,24 @@ const { Text } = Typography;
const UNKNOWN_LAG_COLOR = '#8c8c8c';
const UNAVAILABLE_LAG_LABEL = '不可用';
+/**
+ * The provider leaves delaySeconds at zero when the broker consume stats
carried no
+ * consumed-message timestamp (RocketMQMetadataProvider: "keep zeros") and
says so through
+ * consumptionTimestampAvailable. Rendering that zero claims the group is
caught up, so the
+ * column and its order have to treat it as unknown.
+ */
+const isDelayAvailable = (group: ConsumerGroup): boolean =>
+ group.consumptionTimestampAvailable !== false;
+
+const delaySortValue = (group: ConsumerGroup, sortOrder?: SortOrder): number
=> {
+ if (isDelayAvailable(group) && Number.isFinite(group.delaySeconds)) {
+ return group.delaySeconds;
+ }
+ // Ant Design negates the comparator for a descending column, so the
sentinel has to flip with
+ // the direction to keep unmeasured rows after measured ones in both orders.
+ return sortOrder === 'descend' ? Number.MIN_SAFE_INTEGER :
Number.MAX_SAFE_INTEGER;
+};
+
const lagColor = (lag: number): string => {
// The backend reports -1 when the lag cannot be determined; do not color it
// as healthy (green) or backlogged.
@@ -1044,7 +1063,8 @@ const ConsumerPageContent = ({
key: 'totalLag',
width: 96,
align: 'right',
- sorter: (a, b) => lagSortValue(a.totalLag) - lagSortValue(b.totalLag),
+ sorter: (a, b, sortOrder) =>
+ lagSortValue(a.totalLag, sortOrder) - lagSortValue(b.totalLag,
sortOrder),
render: (lag: number) =>
isLagAvailable(lag) ? (
lag.toLocaleString()
@@ -1058,8 +1078,13 @@ const ConsumerPageContent = ({
key: 'delaySeconds',
width: 100,
align: 'right',
- sorter: (a, b) => (a.delaySeconds ?? 0) - (b.delaySeconds ?? 0),
- render: (seconds: number) => formatDelay(seconds ?? 0, lang),
+ sorter: (a, b, sortOrder) => delaySortValue(a, sortOrder) -
delaySortValue(b, sortOrder),
+ render: (seconds: number, record: ConsumerGroup) =>
+ isDelayAvailable(record) ? (
+ formatDelay(seconds ?? 0, lang)
+ ) : (
+ <Text type="secondary">{UNAVAILABLE_LAG_LABEL}</Text>
+ ),
},
{
title: '创建时间',
@@ -1859,7 +1884,11 @@ const ConsumerPageContent = ({
</Tag>
</Descriptions.Item>
<Descriptions.Item label="消费延迟">
- <Text strong>{formatDelay(selectedGroup.delaySeconds,
lang)}</Text>
+ <Text strong>
+ {isDelayAvailable(selectedGroup)
+ ? formatDelay(selectedGroup.delaySeconds, lang)
+ : UNAVAILABLE_LAG_LABEL}
+ </Text>
</Descriptions.Item>
<Descriptions.Item label="最大重试次数">
<Text strong>{selectedGroup.retryMaxTimes}</Text> 次
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index bfec41bfc..aee2c601e 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -550,6 +550,37 @@ describe('consumer service mock data', () => {
});
describe('consumer group CSV export', () => {
+ it('renders an unmeasurable consume delay as unknown instead of the
placeholder zero', async () => {
+ const unmeasured = {
+ ...mockConsumerGroups[0],
+ name: 'cg-delay-unmeasured',
+ namespace: 'ns',
+ clusterId: 'cluster-a',
+ subscriptionMode: 'Push' as const,
+ consumeType: 'CLUSTERING' as const,
+ delaySeconds: 0,
+ consumptionTimestampAvailable: false,
+ };
+ const measuredZero = {
+ ...unmeasured,
+ name: 'cg-delay-zero',
+ consumptionTimestampAvailable: true,
+ };
+ mockConsumerGroups.push(unmeasured, measuredZero);
+ try {
+ const csv = await exportConsumerGroups({ names: ['cg-delay-unmeasured',
'cg-delay-zero'] });
+
+ expect(csv).toContain(
+
'"cg-delay-unmeasured","ns","cluster-a","Push","CLUSTERING","0","0","unknown"',
+ );
+ // A measured zero must stay numeric, otherwise the column loses its
meaning entirely.
+
expect(csv).toContain('"cg-delay-zero","ns","cluster-a","Push","CLUSTERING","0","0","0"');
+ } finally {
+ mockConsumerGroups.pop();
+ mockConsumerGroups.pop();
+ }
+ });
+
it('exports an unavailable total lag as unknown while preserving measured
values', async () => {
const unavailable = {
...mockConsumerGroups[0],
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index 8381f33a9..5b3fd0f8c 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -53,7 +53,13 @@ const GROUP_EXPORT_COLUMNS: CsvColumn<ConsumerGroup>[] = [
header: 'Total Lag',
value: (group) => (isLagAvailable(group.totalLag) ? group.totalLag :
'unknown'),
},
- { header: 'Delay Seconds', value: (group) => group.delaySeconds },
+ {
+ header: 'Delay Seconds',
+ // The same placeholder zero as behind the table column: an unmeasured
delay must not be
+ // exported as "0", which reads as caught up.
+ value: (group) =>
+ group.consumptionTimestampAvailable === false ? 'unknown' :
group.delaySeconds,
+ },
{ header: 'Subscription Data Type', value: (group) =>
group.subscriptionDataType },
{ header: 'Delivery Order Type', value: (group) => group.deliveryOrderType },
{ header: 'Retry Max Times', value: (group) => group.retryMaxTimes },
diff --git a/web/src/utils/consumerGroupDiagnostics.test.ts
b/web/src/utils/consumerGroupDiagnostics.test.ts
index 3eaa205fe..19f6cce89 100644
--- a/web/src/utils/consumerGroupDiagnostics.test.ts
+++ b/web/src/utils/consumerGroupDiagnostics.test.ts
@@ -183,4 +183,34 @@ describe('consumer group diagnostics', () => {
expect.arrayContaining(['SUBSCRIPTION_UNKNOWN', 'UNKNOWN_QUEUE_LAG',
'STALE_HEARTBEAT']),
);
});
+
+ it('reports an unmeasurable consume delay as unknown instead of a healthy
zero', () => {
+ const diagnostics = analyzeConsumerGroupHealth(
+ group({ delaySeconds: 0, consumptionTimestampAvailable: false, totalLag:
0 }),
+ [subscription({})],
+ [queue({ diffTotal: 0 })],
+ { now: '2026-08-31T12:01:00Z' },
+ );
+
+ // The provider leaves delaySeconds at zero when the broker stats carried
no consumed-message
+ // timestamp; judging that zero reported the group as caught up rather
than unmeasurable.
+ expect(diagnostics.issues.map((item) =>
item.code)).toContain('CONSUME_DELAY_UNKNOWN');
+ expect(diagnostics.issues.map((item) =>
item.code)).not.toContain('HIGH_CONSUME_DELAY');
+ expect(diagnostics.status).toBe('warning');
+ expect(diagnostics.recommendations).toContain(
+ '消费延迟不可用时,确认 Broker 消费统计可读且消费端仍在提交 offset。',
+ );
+ });
+
+ it('keeps judging a measured delay', () => {
+ const diagnostics = analyzeConsumerGroupHealth(
+ group({ delaySeconds: 1_900, consumptionTimestampAvailable: true,
totalLag: 0 }),
+ [subscription({})],
+ [queue({ diffTotal: 0 })],
+ { now: '2026-08-31T12:01:00Z' },
+ );
+
+ expect(diagnostics.issues.map((item) =>
item.code)).toContain('HIGH_CONSUME_DELAY');
+ expect(diagnostics.issues.map((item) =>
item.code)).not.toContain('CONSUME_DELAY_UNKNOWN');
+ });
});
diff --git a/web/src/utils/consumerGroupDiagnostics.ts
b/web/src/utils/consumerGroupDiagnostics.ts
index c7a3f6883..e402512dc 100644
--- a/web/src/utils/consumerGroupDiagnostics.ts
+++ b/web/src/utils/consumerGroupDiagnostics.ts
@@ -29,6 +29,7 @@ export type ConsumerGroupHealthIssueCode =
| 'QUEUE_LAG_SKEW'
| 'HIGH_GROUP_LAG'
| 'HIGH_CONSUME_DELAY'
+ | 'CONSUME_DELAY_UNKNOWN'
| 'UNKNOWN_QUEUE_LAG'
| 'STALE_HEARTBEAT';
@@ -340,25 +341,38 @@ const runtimeIssues = (
}
}
- const delaySeconds = Number.isFinite(group.delaySeconds) ?
group.delaySeconds : 0;
- if (delaySeconds >= options.criticalDelaySeconds) {
+ if (group.consumptionTimestampAvailable === false) {
+ // delaySeconds is the provider's placeholder zero here, not a
measurement, so judging it
+ // would report an unmeasurable group as caught up. Say the delay is
unknown instead.
issues.push(
issue(
- 'HIGH_CONSUME_DELAY',
- 'critical',
- '消费延迟过高',
- `Group 当前消费延迟约 ${delaySeconds.toLocaleString()} 秒,业务可能已经感知延迟。`,
- ),
- );
- } else if (delaySeconds >= options.highDelaySeconds) {
- issues.push(
- issue(
- 'HIGH_CONSUME_DELAY',
+ 'CONSUME_DELAY_UNKNOWN',
'warning',
- '消费延迟偏高',
- `Group 当前消费延迟约 ${delaySeconds.toLocaleString()} 秒,建议继续观察趋势。`,
+ '消费延迟不可用',
+ 'Broker 消费统计未返回已消费消息时间戳,无法判断该 Group 的消费延迟。',
),
);
+ } else {
+ const delaySeconds = Number.isFinite(group.delaySeconds) ?
group.delaySeconds : 0;
+ if (delaySeconds >= options.criticalDelaySeconds) {
+ issues.push(
+ issue(
+ 'HIGH_CONSUME_DELAY',
+ 'critical',
+ '消费延迟过高',
+ `Group 当前消费延迟约 ${delaySeconds.toLocaleString()} 秒,业务可能已经感知延迟。`,
+ ),
+ );
+ } else if (delaySeconds >= options.highDelaySeconds) {
+ issues.push(
+ issue(
+ 'HIGH_CONSUME_DELAY',
+ 'warning',
+ '消费延迟偏高',
+ `Group 当前消费延迟约 ${delaySeconds.toLocaleString()} 秒,建议继续观察趋势。`,
+ ),
+ );
+ }
}
return issues;
};
@@ -385,6 +399,9 @@ const recommendations = (issues:
ConsumerGroupHealthIssue[]): string[] => {
if (codes.has('UNKNOWN_QUEUE_LAG')) {
result.push('当堆积不可用时,优先确认 Proxy 指标采集和 Broker offset 查询权限。');
}
+ if (codes.has('CONSUME_DELAY_UNKNOWN')) {
+ result.push('消费延迟不可用时,确认 Broker 消费统计可读且消费端仍在提交 offset。');
+ }
return result;
};
diff --git a/web/src/utils/consumerLag.test.ts
b/web/src/utils/consumerLag.test.ts
index 8d3da77a3..96a799c9e 100644
--- a/web/src/utils/consumerLag.test.ts
+++ b/web/src/utils/consumerLag.test.ts
@@ -42,4 +42,12 @@ describe('consumer lag helpers', () => {
expect(lagSortValue(UNKNOWN_LAG)).toBe(Number.MAX_SAFE_INTEGER);
expect(lagSortValue(null)).toBe(Number.MAX_SAFE_INTEGER);
});
+
+ it('keeps unknown lags after known ones in the descending order too', () => {
+ // Ant Design negates the comparator for a descending column, so an
unknown row would land
+ // first if the sentinel stayed the largest number.
+ expect(lagSortValue(UNKNOWN_LAG, 'descend')).toBe(Number.MIN_SAFE_INTEGER);
+ expect(lagSortValue(null, 'descend')).toBe(Number.MIN_SAFE_INTEGER);
+ expect(lagSortValue(15000, 'descend')).toBe(15000);
+ });
});
diff --git a/web/src/utils/consumerLag.ts b/web/src/utils/consumerLag.ts
index ffb6e8392..052a73bc3 100644
--- a/web/src/utils/consumerLag.ts
+++ b/web/src/utils/consumerLag.ts
@@ -28,6 +28,17 @@ export const formatLag = (
unavailableLabel: string = String(UNKNOWN_LAG),
): string => (isLagAvailable(lag) ? lag.toLocaleString() : unavailableLabel);
-/** Sort key that pushes unknown lags to the end of an ascending list. */
-export const lagSortValue = (lag: number | null | undefined): number =>
- isLagAvailable(lag) ? lag : Number.MAX_SAFE_INTEGER;
+/**
+ * Sort key that keeps an unknown lag after every known value in both sort
directions.
+ *
+ * Ant Design computes a descending column by negating the comparator's
result, so an unknown
+ * value cannot simply be the largest number: the sentinel has to flip with
the direction, or the
+ * descending order lists the rows that carry no measurement first.
+ */
+export const lagSortValue = (
+ lag: number | null | undefined,
+ sortOrder?: 'ascend' | 'descend' | null,
+): number => {
+ if (isLagAvailable(lag)) return lag;
+ return sortOrder === 'descend' ? Number.MIN_SAFE_INTEGER :
Number.MAX_SAFE_INTEGER;
+};