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'.
  */

Reply via email to