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 3d1641bad fix(audit,acl): serialize page timestamps on the UTC wire
(#6686)
3d1641bad is described below
commit 3d1641bad17ddd4c7a5d9fff7252dfc0028d7bf2
Author: dxz0069 <[email protected]>
AuthorDate: Mon Oct 19 20:09:39 2026 +0800
fix(audit,acl): serialize page timestamps on the UTC wire (#6686)
Verified against `rocketmq-studio` at 2cb82a81d before merging: the defect
is still present on the
current trunk and the change merges cleanly.
Merged as-is (squash); no maintainer edits were needed.
Locally verified: targeted suites green on the integration branch; a full
backend and frontend
build plus test pass runs on the trunk once this batch lands.
---
rmqctl/internal/catalog/catalog_gen.go | 2 +-
.../rocketmq/studio/instance/acl/AclService.java | 98 ++++++++--
.../rocketmq/studio/ops/audit/AuditService.java | 49 ++++-
.../main/resources/tool-catalog/tools/audit.yaml | 2 +
.../studio/instance/acl/AclServiceTest.java | 216 +++++++++++++++++++++
.../studio/ops/audit/AuditServiceTest.java | 51 +++++
web/src/pages/instance/__tests__/AclPage.test.tsx | 35 ++++
web/src/pages/instance/acl.tsx | 8 +-
web/src/pages/ops/AuditRiskInsights.tsx | 6 +-
.../pages/ops/ResourceOperationTimelineModal.tsx | 4 +-
web/src/pages/ops/__tests__/AuditPage.test.tsx | 34 ++++
web/src/pages/ops/audit.tsx | 7 +-
web/src/pages/ops/auditRiskInsightModel.ts | 7 +-
web/src/utils/format.ts | 20 +-
14 files changed, 505 insertions(+), 34 deletions(-)
diff --git a/rmqctl/internal/catalog/catalog_gen.go
b/rmqctl/internal/catalog/catalog_gen.go
index fedfdff2a..c286bb75c 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
var defaultDocument = Document{
Version: "2.0.0",
MinimumClientVersion: "2.0.0",
- Digest:
"66ac58f23fbec620f3a0127e901a89f4bcad6c7ee08dd54cbd8f304730ed2199",
+ Digest:
"e7622ffd8d0017ac44ec759b9a76a475422c4098394968cbc65d2b70153396d9",
Tools: []Tool{
{
Name: "rmq.acl.list",
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 fb34a28c4..f3e93bbe7 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
@@ -43,6 +43,8 @@ import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
@@ -108,12 +110,12 @@ public class AclService {
.filter(rule -> equalsIgnoreCase(rule.getScope(), scope))
.filter(rule -> equalsIgnoreCase(rule.getDecision(),
decision))
.toList();
- return paginateRules(filtered, normalizedPage, normalizedPageSize);
+ 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 aclRepository.findRulePage(principal, resource, scope,
decision, null,
- normalizedPage, normalizedPageSize);
+ return toUtcWire(aclRepository.findRulePage(principal, resource,
scope, decision, null,
+ normalizedPage, normalizedPageSize));
}
@@ -132,6 +134,8 @@ public class AclService {
rule.setGmtCreate(LocalDateTime.now());
AclRuleVO saved = aclRepository.saveRule(rule);
auditRule("CREATE_ACL_RULE", saved);
+ // Write paths echo the stored row (the stored server-local base) for
form prefill; the
+ // read paths are what the pages render, so only they convert to the
UTC wire.
return saved;
}
@@ -140,14 +144,14 @@ public class AclService {
throw new BusinessException(400, "ACL rule id is required");
}
if (isTencentInstance(instanceId)) {
- return tencentAclService.listRules(instanceId, null).stream()
+ return toUtcWire(tencentAclService.listRules(instanceId,
null).stream()
.filter(rule -> id.equals(rule.getPrincipal())
|| rule.getId() != null &&
id.equals(rule.getId().toString()))
.findFirst()
- .orElseThrow(() -> new BusinessException(404, "ACL rule
not found: " + id));
+ .orElseThrow(() -> new BusinessException(404, "ACL rule
not found: " + id)));
}
- return aclRepository.findRuleById(EntityIds.parseId(id))
- .orElseThrow(() -> new BusinessException(404, "ACL rule not
found: " + id));
+ return toUtcWire(aclRepository.findRuleById(EntityIds.parseId(id))
+ .orElseThrow(() -> new BusinessException(404, "ACL rule not
found: " + id)));
}
public AclUserVO getUser(String id, String instanceId) {
@@ -193,12 +197,12 @@ public class AclService {
public List<AclUserVO> listUsers(String instanceId) {
if (isTencentInstance(instanceId)) {
return tencentAclService.listUsers(instanceId).stream()
- .map(this::maskCredentials)
+ .map(this::forWire)
.toList();
}
log.info("Listing ACL users");
return aclRepository.findUsers().stream()
- .map(this::maskCredentials)
+ .map(this::forWire)
.toList();
}
@@ -218,12 +222,12 @@ public class AclService {
int from = (int) Math.min(Pagination.pageOffset(page, pageSize),
users.size());
int to = Math.min(from + pageSize, users.size());
return PageResult.of(
- users.subList(from,
to).stream().map(this::maskCredentials).toList(),
+ users.subList(from,
to).stream().map(this::forWire).toList(),
users.size(), page, pageSize);
}
PageResult<AclUserVO> result = aclRepository.findUserPage(
StringUtils.hasText(keyword) ? keyword.trim() : null, page,
pageSize);
- return
PageResult.of(result.getItems().stream().map(this::maskCredentials).toList(),
+ return
PageResult.of(result.getItems().stream().map(this::forWire).toList(),
result.getTotal(), result.getPage(), result.getSize());
}
@@ -242,6 +246,8 @@ public class AclService {
user.setGmtCreate(LocalDateTime.now());
AclUserVO saved = aclRepository.saveUser(user);
auditUser("CREATE_ACL_USER", saved);
+ // Write paths echo the stored row (the stored server-local base);
only the read paths
+ // that the pages render convert to the UTC wire.
return saved;
}
@@ -574,7 +580,7 @@ public class AclService {
// Both vendor paths reveal a secret, so both are audited; the detail
never carries it.
recordAudit("REVEAL_ACL_USER_CREDENTIALS", "ACL_USER",
String.valueOf(id), null,
"instanceId=" + instanceId);
- return user;
+ return withUtcWire(user);
}
/**
@@ -738,6 +744,7 @@ public class AclService {
};
}
+ /** Write paths echo the stored row, so masking must not also shift its
creation stamp. */
private AclUserVO maskCredentials(AclUserVO user) {
return AclUserVO.builder()
.id(user.getId())
@@ -753,6 +760,73 @@ public class AclService {
.build();
}
+ /**
+ * The read-path wire shape: a masked copy whose creation stamp is UTC.
maskCredentials already
+ * builds a fresh row, so the shift lands on the copy and the stored base
stays server-local.
+ */
+ private AclUserVO forWire(AclUserVO user) {
+ AclUserVO masked = maskCredentials(user);
+ masked.setGmtCreate(toUtcWire(masked.getGmtCreate()));
+ return masked;
+ }
+
+ /**
+ * ACL timestamps are stored as zone-less server-local values (every
source produces that
+ * shape: the local repositories write {@code LocalDateTime.now()}, the
Tencent adapter
+ * converts cloud epochs with the server zone). The pages render them in
the viewer's zone,
+ * so the JSON wire carries a UTC wall clock — the alert / K8s-cert
convention — instead of a
+ * zone-less value the browser would parse as its own zone. The stored
base is not converted,
+ * and these mappers return copies so repository- or adapter-owned rows
stay untouched.
+ */
+ private static LocalDateTime toUtcWire(LocalDateTime serverLocal) {
+ return serverLocal == null ? null :
serverLocal.atZone(ZoneId.systemDefault())
+ .withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
+ }
+
+ private static AclRuleVO toUtcWire(AclRuleVO rule) {
+ if (rule == null) {
+ return null;
+ }
+ return AclRuleVO.builder()
+ .id(rule.getId())
+ .principal(rule.getPrincipal())
+ .resource(rule.getResource())
+ .resourceType(rule.getResourceType())
+ .resourcePattern(rule.getResourcePattern())
+ .actions(rule.getActions() == null ? null :
List.copyOf(rule.getActions()))
+ .decision(rule.getDecision())
+ .scope(rule.getScope())
+ .aclVersion(rule.getAclVersion())
+ .gmtCreate(toUtcWire(rule.getGmtCreate()))
+ .build();
+ }
+
+ private static AclUserVO withUtcWire(AclUserVO user) {
+ if (user == null) {
+ return null;
+ }
+ return AclUserVO.builder()
+ .id(user.getId())
+ .username(user.getUsername())
+ .accessKey(user.getAccessKey())
+ .secretKey(user.getSecretKey())
+ .admin(user.isAdmin())
+ .permRead(user.getPermRead())
+ .permWrite(user.getPermWrite())
+ .clusters(user.getClusters() == null ? null :
List.copyOf(user.getClusters()))
+ .whiteRemoteAddress(user.getWhiteRemoteAddress())
+ .gmtCreate(toUtcWire(user.getGmtCreate()))
+ .build();
+ }
+
+ private static PageResult<AclRuleVO> toUtcWire(PageResult<AclRuleVO> page)
{
+ if (page == null) {
+ return null;
+ }
+ return
PageResult.of(page.getItems().stream().map(AclService::toUtcWire).toList(),
+ page.getTotal(), page.getPage(), page.getSize());
+ }
+
private static int normalizePage(Integer page) {
return page == null || page < 1 ? 1 : page;
}
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 563bb98fa..390ec6722 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
@@ -29,6 +29,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
+import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
@@ -57,8 +58,19 @@ public class AuditService {
log.info("Querying audit logs, page={}, pageSize={}, search={},
operationType={}, result={}",
page, pageSize, search, operationType, result);
- return findPage(search, operationType, resourceType, target,
clusterId, clusterIdMissing,
- startDate, endDate, result, page, pageSize);
+ PageResult<AuditRecordVO> stored = findPage(search, operationType,
resourceType, target,
+ clusterId, clusterIdMissing, startDate, endDate, result, page,
pageSize);
+ if (stored == null || stored.getItems() == null) {
+ return stored;
+ }
+ // The page renders these timestamps in the viewer's zone, so the JSON
wire carries a UTC
+ // wall clock like the alert / K8s-cert family; a zone-less
server-local value would be
+ // parsed as browser-local and shift by the server-viewer offset. The
stored base and the
+ // CSV export keep the server-local wall clock, and the CSV header
keeps naming the
+ // server's offset.
+ return PageResult.of(
+
stored.getItems().stream().map(AuditService::toUtcWire).toList(),
+ stored.getTotal(), stored.getPage(), stored.getSize());
}
public AuditFilterOptionsVO getFilterOptions() {
@@ -69,8 +81,15 @@ public class AuditService {
String clusterId, String startDate, String
endDate, String result) {
DateRange range = parseDateRange(startDate, endDate);
String normalizedSearch = normalizeSearch(search);
- return auditRepository.summarize(normalizedSearch, operationType,
resourceType, clusterId,
- range.start(), range.end(), result);
+ AuditSummaryVO summary = auditRepository.summarize(normalizedSearch,
operationType,
+ resourceType, clusterId, range.start(), range.end(), result);
+ // latestAt rides the same wire as the page rows: zone-less
server-local in storage,
+ // UTC for every JSON consumer, while the audit CSV keeps naming the
server offset.
+ if (summary != null && summary.getLatestAt() != null) {
+
summary.setLatestAt(summary.getLatestAt().atZone(ZoneId.systemDefault())
+ .withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime());
+ }
+ return summary;
}
public String exportLogs(String search, String operationType, String
resourceType,
@@ -115,6 +134,28 @@ public class AuditService {
return "UTC" + (offset.getTotalSeconds() == 0 ? "" : offset.getId());
}
+ /** Returns a copy with the timestamp shifted to the UTC wire; the stored
row keeps its base. */
+ private static AuditRecordVO toUtcWire(AuditRecordVO record) {
+ if (record == null) {
+ return null;
+ }
+ AuditRecordVO wire = AuditRecordVO.builder()
+ .operator(record.getOperator())
+ .operationType(record.getOperationType())
+ .resourceType(record.getResourceType())
+ .target(record.getTarget())
+ .clusterId(record.getClusterId())
+ .detail(record.getDetail())
+ .result(record.getResult())
+ .errorMessage(record.getErrorMessage())
+ .timestamp(record.getTimestamp() == null ? null :
record.getTimestamp()
+ .atZone(ZoneId.systemDefault())
+ .withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime())
+ .build();
+ wire.setId(record.getId());
+ return wire;
+ }
+
public void record(String operationType, String target, String detail,
String result) {
record(operationType, target, null, detail, result);
diff --git a/server/src/main/resources/tool-catalog/tools/audit.yaml
b/server/src/main/resources/tool-catalog/tools/audit.yaml
index 01d240677..8609e7ef8 100644
--- a/server/src/main/resources/tool-catalog/tools/audit.yaml
+++ b/server/src/main/resources/tool-catalog/tools/audit.yaml
@@ -61,6 +61,8 @@ tools:
type:
- string
- 'null'
+ description: When the entry was recorded, as an offset-less
UTC wall clock (the
+ JSON wire convention; parse it as UTC, not as the local
zone).
operator:
type:
- string
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 97529a464..f1213d4ae 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
@@ -54,6 +54,7 @@ import java.util.Map;
import java.util.Locale;
import java.util.Optional;
import java.util.Properties;
+import java.util.TimeZone;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -62,6 +63,7 @@ import static
org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -424,6 +426,220 @@ class AclServiceTest {
verify(aclRepository).findUsers();
}
+ @Test
+ void listUsersShouldSerializeGmtCreateAsUtcWallTimeTest() {
+ // gmtCreate is stored as a zone-less server-local wall clock and the
page renders it in
+ // the viewer's zone, so the wire carries UTC — the same convention
the audit page uses.
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AclUserVO stored = AclUserVO.builder()
+ .username("orders")
+ .accessKey("access-key-123456")
+ .secretKey("secret-key-987654")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+ when(aclRepository.findUsers()).thenReturn(List.of(stored));
+
+ List<AclUserVO> result = aclService.listUsers(null);
+
+ assertThat(result.get(0).getGmtCreate())
+ .isEqualTo(LocalDateTime.of(2026, 7, 23, 2, 0, 0));
+ // The stored row itself keeps the server-local base.
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void listRulesShouldSerializeGmtCreateAsUtcWallTimeTest() {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AclRuleVO stored = AclRuleVO.builder()
+ .principal("user1")
+ .resource("topic-1")
+ .decision("ALLOW")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+ when(aclRepository.findRulePage(null, null, null, null, null, 1,
20))
+ .thenReturn(PageResult.of(List.of(stored), 1, 1, 20));
+
+ PageResult<AclRuleVO> result = aclService.listRules(null, null,
null, null, null,
+ null, null);
+
+ assertThat(result.getItems().get(0).getGmtCreate())
+ .isEqualTo(LocalDateTime.of(2026, 7, 23, 2, 0, 0));
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void listRulesShouldSerializeTencentGmtCreateAsUtcWallTimeTest() {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ InstanceVO instance = InstanceVO.builder()
+ .name("tencent-instance")
+ .vendor(InstanceVendor.TENCENT)
+ .type(InstanceType.CLOUD)
+ .build();
+
when(instanceResolver.findByIdentifier("tencent-instance")).thenReturn(Optional.of(instance));
+ AclRuleVO stored = AclRuleVO.builder()
+ .principal("role-a")
+ .resource("topic-a")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+ when(tencentAclService.listRules("tencent-instance",
null)).thenReturn(List.of(stored));
+
+ PageResult<AclRuleVO> result = aclService.listRules(null, null,
null, null,
+ "tencent-instance", 1, 20);
+
+ assertThat(result.getItems().get(0).getGmtCreate())
+ .isEqualTo(LocalDateTime.of(2026, 7, 23, 2, 0, 0));
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void getRuleShouldSerializeTheStoredCreationStampAsUtcWallTimeTest() {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AclRuleVO stored = AclRuleVO.builder()
+ .principal("user1")
+ .resource("topic-1")
+ .decision("ALLOW")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+ stored.setId(7L);
+
when(aclRepository.findRuleById(7L)).thenReturn(Optional.of(stored));
+
+ AclRuleVO result = aclService.getRule("7", null);
+
+ assertThat(result.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 2, 0, 0));
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void pageUsersShouldMaskAndSerializeGmtCreateAsUtcWallTimeTest() {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AclUserVO stored = AclUserVO.builder()
+ .id(11L)
+ .username("orders")
+ .accessKey("access-key-123456")
+ .secretKey("secret-key-987654")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+ when(aclRepository.findUserPage(isNull(), eq(1), eq(20)))
+ .thenReturn(PageResult.of(List.of(stored), 1, 1, 20));
+
+ PageResult<AclUserVO> result = aclService.pageUsers(null, 1, 20,
null);
+
+ assertThat(result.getItems().get(0).getGmtCreate())
+ .isEqualTo(LocalDateTime.of(2026, 7, 23, 2, 0, 0));
+
assertThat(result.getItems().get(0).getAccessKey()).isEqualTo("acce****3456");
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void listUsersShouldSerializeTencentGmtCreateAsUtcWallTimeTest() {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ InstanceVO instance = InstanceVO.builder()
+ .name("tencent-instance")
+ .vendor(InstanceVendor.TENCENT)
+ .type(InstanceType.CLOUD)
+ .build();
+
when(instanceResolver.findByIdentifier("tencent-instance")).thenReturn(Optional.of(instance));
+ AclUserVO stored = AclUserVO.builder()
+ .username("role-user")
+ .accessKey("access-key-123456")
+ .secretKey("secret-key-987654")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+
when(tencentAclService.listUsers("tencent-instance")).thenReturn(List.of(stored));
+
+ List<AclUserVO> result = aclService.listUsers("tencent-instance");
+
+
assertThat(result.get(0).getGmtCreate()).isEqualTo(LocalDateTime.of(2026, 7,
23, 2, 0, 0));
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void
getUserCredentialsShouldKeepTheSecretRawAndShiftTheCreationStampTest() {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AclUserVO stored = AclUserVO.builder()
+ .id(1L)
+ .username("orders")
+ .accessKey("access-key-123456")
+ .secretKey("secret-key-987654")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+
when(aclRepository.findUserById(1L)).thenReturn(Optional.of(stored));
+
+ AclUserVO result = aclService.getUserCredentials("1", null);
+
+ // The reveal dialog shows the raw credentials exactly once; only
the creation stamp
+ // moves to the UTC wire, and the stored row keeps its
server-local base.
+ assertThat(result.getAccessKey()).isEqualTo("access-key-123456");
+ assertThat(result.getSecretKey()).isEqualTo("secret-key-987654");
+ assertThat(result.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 2, 0, 0));
+ assertThat(stored.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void updateUserShouldEchoTheStoredCreationStampUnshiftedTest() {
+ // The write paths echo the stored row (masked) so create and update
agree: an update
+ // response must not carry a UTC stamp next to a create response's
server-local one.
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AclUserVO saved = AclUserVO.builder()
+ .id(1L)
+ .username("orders")
+ .accessKey("access-key-123456")
+ .secretKey("secret-key-987654")
+ .gmtCreate(LocalDateTime.of(2026, 7, 23, 10, 0, 0))
+ .build();
+
when(aclRepository.findUserById(1L)).thenReturn(Optional.of(saved));
+ when(aclRepository.replaceUser(any(AclUserVO.class)))
+ .thenAnswer(invocation ->
Optional.of(invocation.getArgument(0)));
+
+ UpdateAclUserDTO command = new UpdateAclUserDTO();
+ command.setId("1");
+ command.setUsername("orders");
+ AclUserVO result = aclService.updateUser(command, null);
+
+ assertThat(result.getAccessKey()).isEqualTo("acce****3456");
+ assertThat(result.getGmtCreate()).isEqualTo(LocalDateTime.of(2026,
7, 23, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
@ParameterizedTest
@MethodSource("credentialMasks")
void listUsersShouldMaskCredentialLengthBoundaries(String credential,
String expected) {
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 02ac20a9f..c1f1e9c1f 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
@@ -105,6 +105,57 @@ class AuditServiceTest {
eq("prod-cn"), eq(false), isNull(), isNull(), eq("SUCCESS"),
eq(2), eq(20));
}
+ @Test
+ void queryLogsShouldSerializeTimestampsAsUtcWallTimeTest() {
+ // Audit rows are stored as zone-less server-local wall clocks; the
page renders them in
+ // the viewer's zone, so the JSON wire carries UTC. A UTC container
viewed from UTC+8
+ // would otherwise show every audit time eight hours off while the CSV
export of the
+ // same rows stays correct.
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AuditRecordVO stored = AuditRecordVO.builder()
+ .operationType("CREATE")
+ .timestamp(LocalDateTime.of(2026, 8, 1, 10, 0, 0))
+ .build();
+ when(auditRepository.findPage(isNull(), isNull(), isNull(),
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, false, null, null,
null);
+
+ assertThat(result.getItems().get(0).getTimestamp())
+ .isEqualTo(LocalDateTime.of(2026, 8, 1, 2, 0, 0));
+ // The wire row is a copy: the stored row keeps its server-local
base.
+ assertThat(stored.getTimestamp()).isEqualTo(LocalDateTime.of(2026,
8, 1, 10, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void summarizeShouldSerializeLatestAtAsUtcWallTimeTest() {
+ // latestAt rides the same wire as the page rows: zone-less
server-local in storage, UTC
+ // for every JSON consumer, while the audit CSV keeps naming the
server offset.
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ try {
+ AuditSummaryVO stored = AuditSummaryVO.builder()
+ .total(3)
+ .latestAt(LocalDateTime.of(2026, 8, 1, 10, 0, 0))
+ .build();
+ when(auditRepository.summarize(isNull(), isNull(), isNull(),
isNull(),
+ isNull(), isNull(), isNull())).thenReturn(stored);
+
+ AuditSummaryVO result = auditService.summarize(null, null, null,
null, null, null, null);
+
+ assertThat(result.getLatestAt()).isEqualTo(LocalDateTime.of(2026,
8, 1, 2, 0, 0));
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
@Test
void queryLogsParsesDateRangeBeforeDelegating() {
when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index 1b6a61fa8..5b5785c12 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -251,6 +251,41 @@ describe('ACL page', () => {
);
});
+ it('renders creation times in the viewer timezone from the offset-less UTC
wire format', async () => {
+ // gmtCreate arrives as a UTC wall clock without an offset; a plain Date
parse would read
+ // the server's clock as the viewer's own and shift every creation time by
the difference.
+ vi.stubEnv('TZ', 'Asia/Shanghai');
+ try {
+ vi.mocked(aclService.listAclRules).mockResolvedValue({
+ items: [
+ {
+ id: 21,
+ principal: 'remote-user',
+ resource: 'remote-topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: 2,
+ gmtCreate: '2026-07-23T02:00:00',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+
+ renderWithProviders(<AclPage />);
+
+ // The default tab is the users list; the rule creation times live on
the rules tab.
+ await openRulesTab(userEvent.setup({ pointerEventsCheck: 0 }));
+
+ expect(await screen.findByText('2026-07-23 10:00')).toBeInTheDocument();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
it('reloads the server rule page after deleting one ACL rule', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 15f5574c7..6e945c57a 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -80,6 +80,7 @@ import type {
} from '../../api/acl';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { tableScrollX } from '../../utils/table';
+import { parseUtcDateTime } from '../../utils/format';
import { analyzeAclRisk, type AclRiskIssue } from
'../../utils/aclRiskDiagnostics';
type AclEntityId = AclRule['id'];
@@ -736,8 +737,11 @@ const AclPageContent = ({
const formatDate = (iso?: string | null) => {
if (!iso) return '-';
- const d = new Date(iso);
- if (Number.isNaN(d.getTime())) return '-';
+ // gmtCreate arrives as a UTC wall clock without an offset; a plain Date
parse would read the
+ // server's clock as the viewer's own and shift every creation time by the
zone difference.
+ const millis = parseUtcDateTime(iso);
+ if (Number.isNaN(millis)) return '-';
+ const d = new Date(millis);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2,
'0')}-${String(d.getDate()).padStart(2, '0')}
${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2,
'0')}`;
};
diff --git a/web/src/pages/ops/AuditRiskInsights.tsx
b/web/src/pages/ops/AuditRiskInsights.tsx
index 45267f2cf..9e4d34104 100644
--- a/web/src/pages/ops/AuditRiskInsights.tsx
+++ b/web/src/pages/ops/AuditRiskInsights.tsx
@@ -33,7 +33,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { AuditSummary } from '../../api/audit';
import type { AuditRecord } from '../../api/ops';
import { useLang } from '../../i18n/LangContext';
-import { formatDateTime, formatNumber } from '../../utils/format';
+import { formatUtcDateTime, formatNumber } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
import TagList from '../../components/TagList';
import {
@@ -169,7 +169,7 @@ const AuditRiskInsights = ({ summary, records, loading }:
Props) => {
dataIndex: 'latestAt',
key: 'latestAt',
width: 180,
- render: (latestAt: string | null) => formatDateTime(latestAt),
+ render: (latestAt: string | null) => formatUtcDateTime(latestAt),
},
];
@@ -179,7 +179,7 @@ const AuditRiskInsights = ({ summary, records, loading }:
Props) => {
dataIndex: 'timestamp',
key: 'timestamp',
width: 180,
- render: (timestamp: string) => formatDateTime(timestamp),
+ render: (timestamp: string) => formatUtcDateTime(timestamp),
},
{
title: t('audit.operator'),
diff --git a/web/src/pages/ops/ResourceOperationTimelineModal.tsx
b/web/src/pages/ops/ResourceOperationTimelineModal.tsx
index b54f72baf..d269be9e3 100644
--- a/web/src/pages/ops/ResourceOperationTimelineModal.tsx
+++ b/web/src/pages/ops/ResourceOperationTimelineModal.tsx
@@ -38,7 +38,7 @@ import type { AuditFilter } from '../../api/audit';
import type { AuditRecord } from '../../api/ops';
import { exportAuditLogs, listAuditRecords } from '../../services/opsService';
import { downloadBlob } from '../../utils/download';
-import { formatDateTime } from '../../utils/format';
+import { formatUtcDateTime } from '../../utils/format';
import { useLang } from '../../i18n/LangContext';
import {
getAuditOperationPresentation,
@@ -209,7 +209,7 @@ const ResourceOperationTimelineModal = ({
</Tag>
</Flex>
<Typography.Text type="secondary">
- {formatDateTime(record.timestamp)} · {record.operator
|| '-'}
+ {formatUtcDateTime(record.timestamp)} ·
{record.operator || '-'}
</Typography.Text>
{detail && <Typography.Text>{detail}</Typography.Text>}
{record.errorMessage && (
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index e168c08c1..05226a79e 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -186,6 +186,40 @@ describe('Audit page', () => {
expect(screen.getByText('timestamp: 1784246400000')).toBeInTheDocument();
});
+ it('renders the audit timestamp in the viewer timezone from the offset-less
UTC wire format', async () => {
+ // The backend serializes audit timestamps as UTC wall clocks without an
offset (the stored
+ // base stays server-local). Parsing them as browser-local would shift
every audit time by
+ // the server-viewer offset while the CSV export of the same rows stays
correct.
+ vi.stubEnv('TZ', 'Asia/Shanghai');
+ try {
+ vi.mocked(opsService.listAuditRecords).mockResolvedValueOnce({
+ items: [
+ {
+ id: 3,
+ timestamp: '2026-08-01T02:00:00',
+ operator: 'ops-chen',
+ operationType: 'RELOAD_PROXY_CONFIG',
+ resourceType: 'PROXY',
+ target: '10.0.30.10:8081',
+ clusterId: 'prod-cn',
+ detail: '',
+ result: 'SUCCESS',
+ errorMessage: '',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+
+ renderWithProviders(<AuditPage />);
+
+ expect(await screen.findByText('2026-08-01
10:00:00')).toBeInTheDocument();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
it('opens a resource timeline from a non-empty audit target', async () => {
const user = userEvent.setup();
renderWithProviders(<AuditPage />);
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index dc9f537af..62827b271 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -49,7 +49,7 @@ import {
listAuditRecords,
} from '../../services/opsService';
import { downloadBlob } from '../../utils/download';
-import { formatDateTime } from '../../utils/format';
+import { formatUtcDateTime } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
import {
describeAuditRecord,
@@ -333,9 +333,10 @@ const AuditPage: React.FC = () => {
title: t('audit.time'),
dataIndex: 'timestamp',
width: 180,
- sorter: (a, b) => new Date(a.timestamp).getTime() - new
Date(b.timestamp).getTime(),
+ // The wire carries a UTC wall clock; parsing it as browser-local would
shift the render
+ // by the server-viewer offset. The sorter is left to #6687's removal
direction.
defaultSortOrder: 'descend',
- render: (timestamp: string) => formatDateTime(timestamp),
+ render: (timestamp: string) => formatUtcDateTime(timestamp),
},
{
title: t('audit.operator'),
diff --git a/web/src/pages/ops/auditRiskInsightModel.ts
b/web/src/pages/ops/auditRiskInsightModel.ts
index 3ae719ff9..cc1022eaf 100644
--- a/web/src/pages/ops/auditRiskInsightModel.ts
+++ b/web/src/pages/ops/auditRiskInsightModel.ts
@@ -17,7 +17,7 @@
import type { AuditSummary } from '../../api/audit';
import type { AuditRecord } from '../../api/ops';
-import { roundPercent } from '../../utils/format';
+import { parseUtcDateTime, roundPercent } from '../../utils/format';
import {
getAuditOperationPresentation,
isControlPlaneAuditRecord,
@@ -175,8 +175,9 @@ export const isHighRiskAuditOperation = (operationType:
string | null | undefine
};
const compareTimestampDesc = (left: string | null, right: string | null):
number => {
- const leftTime = left ? new Date(left).getTime() : 0;
- const rightTime = right ? new Date(right).getTime() : 0;
+ // Audit timestamps arrive as offset-less UTC wall clocks; parse them as UTC
like the renders.
+ const leftTime = left ? parseUtcDateTime(left) || 0 : 0;
+ const rightTime = right ? parseUtcDateTime(right) || 0 : 0;
return rightTime - leftTime;
};
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 1b5b7fde8..90084a997 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -63,10 +63,7 @@ export function formatUtcDateTime(
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
): string {
if (date === null || date === undefined || (typeof date === 'string' &&
!date.trim())) return '-';
- const utcDate =
- typeof date === 'string' && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(date.trim())
- ? new Date(`${date}Z`)
- : new Date(date);
+ const utcDate = typeof date === 'string' ? new Date(parseUtcDateTime(date))
: new Date(date);
if (Number.isNaN(utcDate.getTime())) return '-';
const parts = new Intl.DateTimeFormat('en-US', {
@@ -92,6 +89,21 @@ export function formatUtcDateTime(
: '-';
}
+/**
+ * Parse a UTC timestamp serialized without an offset into epoch milliseconds.
Same guard as
+ * {@link formatUtcDateTime}: a value that already names its zone is parsed
as-is, a zone-less
+ * one is read as UTC — without the suffix Date would treat it as
browser-local instead.
+ */
+export function parseUtcDateTime(date: string | null | undefined): number {
+ if (date === null || date === undefined || !date.trim()) return Number.NaN;
+ const trimmed = date.trim();
+ const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(trimmed)
+ ? trimmed
+ : `${trimmed.replace(' ', 'T')}Z`;
+ const parsed = new Date(normalized).getTime();
+ return Number.isNaN(parsed) ? Number.NaN : parsed;
+}
+
/**
* Format a date string or Date object to 'YYYY-MM-DD'.
*/