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 238f05276 fix: report DLQ, consumer-stack and certificate instants as 
UTC (#5197)
238f05276 is described below

commit 238f05276b0584994e326239000bce5857be3c7b
Author: Zhao Jianing <[email protected]>
AuthorDate: Fri Oct 9 18:45:15 2026 +0800

    fix: report DLQ, consumer-stack and certificate instants as UTC (#5197)
    
    Three places rendered a server-local timestamp as if it were UTC, so the 
same instant read
    differently depending on where the server happened to run.
    
    - The DLQ group's last-enqueue time was built with `ZoneId.systemDefault()` 
and rendered by the
      local-time formatter; both ends now use UTC, and the CSV export column 
goes through
      `formatUtcDateTime` too. (#5197)
    - The consumer diagnostics stack capture time had the same defect 
(`LocalDateTime.now()` on the
      server, rendered as UTC on the page). (#5204)
    - K8s certificate validity used `Clock.systemDefaultZone()` while 
`certs.tsx` renders it as UTC; the
      service now uses `Clock.systemUTC()` and the page the UTC formatter. 
(#5511)
    
    `RocketMQDLQProviderTest` + `RocketMQConsumerDiagnosticsProviderTest` + 
`K8sCertServiceTest`: 83 tests
    green with the dev profile, 0 checkstyle violations; `DLQPage` + 
`ConsumerPage` + `K8sCertsPage`:
    84 tests green. Rebased onto today's trunk with the DLQ export, 
ownership-guard and consumer-page
    changes from #5205 / #5417 / #5448 / #5238 / #5121 all intact.
    
    Two no-op `chore: refresh to keep the pull request active` commits on the 
source branches (one empty,
    one `package-lock.json` peer-flag churn) were not carried over.
    
    Folded in #5204 and #5511.
---
 .../studio/cluster/k8s/K8sCertService.java         | 11 ++--
 .../RocketMQConsumerDiagnosticsProvider.java       |  6 +-
 .../provider/apache/RocketMQDLQProvider.java       |  9 ++-
 .../studio/cluster/k8s/K8sCertServiceTest.java     | 64 ++++++++++++++++++++++
 .../RocketMQConsumerDiagnosticsProviderTest.java   | 24 ++++++++
 .../provider/apache/RocketMQDLQProviderTest.java   | 33 +++++++++++
 .../pages/cluster/__tests__/K8sCertsPage.test.tsx  | 35 ++++++++++--
 web/src/pages/cluster/certs.tsx                    |  4 +-
 .../pages/instance/__tests__/ConsumerPage.test.tsx | 50 +++++++++++++++++
 web/src/pages/instance/__tests__/DLQPage.test.tsx  | 36 ++++++++++++
 web/src/pages/instance/consumer.tsx                |  4 +-
 web/src/pages/instance/dlq.tsx                     |  7 ++-
 12 files changed, 265 insertions(+), 18 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
index ce7109a61..76eee81ee 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
@@ -30,7 +30,7 @@ import java.security.cert.CertificateParsingException;
 import java.security.cert.X509Certificate;
 import java.time.Clock;
 import java.time.LocalDateTime;
-import java.time.ZoneId;
+import java.time.ZoneOffset;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Base64;
@@ -50,7 +50,7 @@ public class K8sCertService {
 
     @Autowired
     public K8sCertService(K8sCertRepository k8sCertRepository, 
OperationAuditService operationAuditService) {
-        this(k8sCertRepository, operationAuditService, 
Clock.systemDefaultZone());
+        this(k8sCertRepository, operationAuditService, Clock.systemUTC());
     }
 
     K8sCertService(K8sCertRepository k8sCertRepository, OperationAuditService 
operationAuditService, Clock clock) {
@@ -90,8 +90,11 @@ public class K8sCertService {
         List<String> san = command.getSan();
         if (certificateSupplied) {
             X509Certificate parsed = parseCertificate(command.getCertPem());
-            notBefore = 
LocalDateTime.ofInstant(parsed.getNotBefore().toInstant(), 
ZoneId.systemDefault());
-            notAfter = 
LocalDateTime.ofInstant(parsed.getNotAfter().toInstant(), 
ZoneId.systemDefault());
+            // Serialize certificate instants as UTC wall time: the frontend
+            // (formatUtcDateTime) interprets the zoneless wire value as UTC 
and
+            // renders it in the viewer's zone, matching every other date API.
+            notBefore = 
LocalDateTime.ofInstant(parsed.getNotBefore().toInstant(), ZoneOffset.UTC);
+            notAfter = 
LocalDateTime.ofInstant(parsed.getNotAfter().toInstant(), ZoneOffset.UTC);
             issuer = parsed.getIssuerX500Principal().getName();
             san = extractSubjectAlternativeNames(parsed);
         }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
index bd7a83d0b..8569079af 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
@@ -33,6 +33,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.regex.Matcher;
@@ -105,7 +106,10 @@ public class RocketMQConsumerDiagnosticsProvider 
implements ConsumerDiagnosticsP
         return ConsumerStackTraceVO.builder()
                 .groupName(groupName)
                 .clientId(clientId)
-                .capturedAt(LocalDateTime.now())
+                // The stack capture time follows the backend-wide UTC 
convention for LocalDateTime
+                // values; the frontend renders it through formatUtcDateTime. 
now() in the default
+                // zone would ship a wall clock no viewer outside the server's 
zone can interpret.
+                .capturedAt(LocalDateTime.now(ZoneOffset.UTC))
                 .threadCount(threads.size())
                 .threads(threads)
                 .build();
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index 3365a8fb7..1375fa826 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -64,7 +64,7 @@ import java.nio.charset.CodingErrorAction;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.LocalDateTime;
-import java.time.ZoneId;
+import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.Base64;
 import java.util.Collections;
@@ -161,8 +161,13 @@ public class RocketMQDLQProvider implements DLQProvider {
                     }
                 }
                 if (latestUpdate > 0L) {
+                    // Backend LocalDateTime values are UTC by convention (the 
metrics and alert
+                    // subsystems write ZoneOffset.UTC, and the frontend 
renders no-offset strings
+                    // through formatUtcDateTime). Converting in the server's 
default zone would
+                    // ship a wall clock no viewer can interpret: the 
serialized string carries no
+                    // offset, so a browser in another zone silently shifts 
the displayed time.
                     lastEnqueueTime = LocalDateTime.ofInstant(
-                            Instant.ofEpochMilli(latestUpdate), 
ZoneId.systemDefault());
+                            Instant.ofEpochMilli(latestUpdate), 
ZoneOffset.UTC);
                 }
             }
         } catch (Exception e) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
index 022774bb4..123adafbd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
@@ -27,6 +27,10 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
 import java.time.Clock;
 import java.time.Instant;
 import java.time.LocalDateTime;
@@ -36,6 +40,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.TimeZone;
 import java.util.function.Consumer;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -53,6 +58,33 @@ class K8sCertServiceTest {
     private static final Clock CLOCK = Clock.fixed(
             Instant.parse("2025-07-01T00:00:00Z"), ZoneOffset.UTC);
 
+    /**
+     * Self-signed certificate (CN=k8s-cert-tz-test). Only the instants matter;
+     * the expected values below are derived from this PEM at runtime, so the
+     * fixture never goes stale.
+     */
+    private static final String TEST_CERT_PEM = """
+            -----BEGIN CERTIFICATE-----
+            MIIDFzCCAf+gAwIBAgIUS8uQTyfAomd6WVQ1tFW8ATqGbgIwDQYJKoZIhvcNAQEL
+            BQAwGzEZMBcGA1UEAwwQazhzLWNlcnQtdHotdGVzdDAeFw0yNjEwMDMxNjMxNTla
+            Fw0yNzEwMDMxNjMxNTlaMBsxGTAXBgNVBAMMEGs4cy1jZXJ0LXR6LXRlc3QwggEi
+            MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9LNaNL5urWlpkE01l28cYPCz7
+            biTd0dB8rpCN0TRFbaPfwVrtLhQMl9yNs62pani8uVHcoR7Uugy0K1TzNDuIMs0v
+            /ztby5KTdnGVSYReBMlP7wp6znc6PR+m9EUj+iPWj2qu0B0uK2C29tSeh8Z3oQyD
+            LWmah3WfPkDviDEjF5q8/DgPpJPT6tzgWK2Hh3jHcxgiZrqaIwvCA77Sz4cz+GVt
+            67OIXYSdKe/0zrJsbQh7g0iWZrq1iup3y19Yc1/U0qwTEHO88L7UEAXe/Zv019sz
+            IBhEjIjpdIzFHpNWV8vTx/k+KzXfV7sgHkkNb7bwfnjgXxc89KyIq/Q5parbAgMB
+            AAGjUzBRMB0GA1UdDgQWBBSZnmdGM6T9+cNOgy/PYMjBlOSstjAfBgNVHSMEGDAW
+            gBSZnmdGM6T9+cNOgy/PYMjBlOSstjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+            DQEBCwUAA4IBAQCSBliRrjNdOhAokNP7hVtiXiEs/E1m3QNNYWglcebOSHNt4XMB
+            sp7OSXV1GDvgb0MS/O+jesuhQLrspUnDiJCJsQUnWYZ9fzHLBanAxkiXi+jHlvAo
+            sBENGoRcp4ZGO/nP0xfxhYzcrnfgUuitMB5vCgnbwy48AjVsv4TGHABUetBgn4td
+            OmBY2N8aAhUfh928233ByzCCwqXfuQsc0pSKNJC21ScTZ7WkBOMBC/8moV9pOppH
+            yNw2C1e3c5YgLLrIM+QrcbQ7gfhDkEtPVeChqUDoBu2tEpEODJ0d4bRLPzxvU+fi
+            aqsTjYPp29NKoyKrL7wGyAiDMwsAgXrE16n9
+            -----END CERTIFICATE-----
+            """;
+
     @Mock
     private K8sCertRepository k8sCertRepository;
 
@@ -141,6 +173,38 @@ class K8sCertServiceTest {
         assertThat(expiringCert.getDaysRemaining()).isEqualTo(-1);
     }
 
+    @Test
+    void createCertShouldParseCertificateInstantsAsUtcWallTimeTest() throws 
Exception {
+        // The API serializes notBefore/notAfter as zoneless LocalDateTime; the
+        // frontend contract (formatUtcDateTime) treats those values as UTC. 
The
+        // service used to convert X509 instants with ZoneId.systemDefault(), 
so
+        // on a server whose OS zone is not UTC the stored wall time carried 
the
+        // server offset and every viewer saw a shifted expiry.
+        TimeZone original = TimeZone.getDefault();
+        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+        try {
+            CreateCertDTO command = CreateCertDTO.builder()
+                    .k8sId("tz-check-cert")
+                    .cluster("tz-cluster")
+                    .type("TLS")
+                    .certPem(TEST_CERT_PEM)
+                    .build();
+            when(k8sCertRepository.save(any(K8sCertVO.class)))
+                    .thenAnswer(invocation -> invocation.getArgument(0));
+
+            K8sCertVO result = k8sCertService.createCert(command);
+
+            X509Certificate parsed = (X509Certificate) 
CertificateFactory.getInstance("X.509")
+                    .generateCertificate(new 
ByteArrayInputStream(TEST_CERT_PEM.getBytes(StandardCharsets.UTF_8)));
+            assertThat(result.getNotAfter())
+                    
.isEqualTo(LocalDateTime.ofInstant(parsed.getNotAfter().toInstant(), 
ZoneOffset.UTC));
+            assertThat(result.getNotBefore())
+                    
.isEqualTo(LocalDateTime.ofInstant(parsed.getNotBefore().toInstant(), 
ZoneOffset.UTC));
+        } finally {
+            TimeZone.setDefault(original);
+        }
+    }
+
     @Test
     void createCertShouldCreateAndSaveCert() {
         CreateCertDTO command = CreateCertDTO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.java
index c749c806a..6e2edea56 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.java
@@ -30,6 +30,11 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.TimeZone;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
@@ -67,6 +72,25 @@ class RocketMQConsumerDiagnosticsProviderTest {
         provider = new 
RocketMQConsumerDiagnosticsProvider(runtimeAdminClientResolver, adminFactory, 
properties);
     }
 
+    @Test
+    void getConsumerStackShouldStampTheCaptureTimeAsUtcTest() throws Exception 
{
+        ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+        runningInfo.setJstack("ConsumeMessageThread_1 TID: 12 STATE: 
RUNNABLE\n");
+        when(adminExt.getConsumerRunningInfo("cg-orders", "client-1", 
true)).thenReturn(runningInfo);
+
+        TimeZone original = TimeZone.getDefault();
+        try {
+            // A default-zone capture under Asia/Shanghai is eight hours ahead 
of UTC, so a drift
+            // bound far below that discriminates the UTC conversion without a 
frozen clock.
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            ConsumerStackTraceVO result = 
provider.getConsumerStack("instance-a", "cg-orders", "client-1");
+            assertThat(Duration.between(result.getCapturedAt(),
+                    
LocalDateTime.now(ZoneOffset.UTC)).abs().getSeconds()).isLessThan(60);
+        } finally {
+            TimeZone.setDefault(original);
+        }
+    }
+
     @Test
     void getConsumerStackShouldUseSelectedInstanceAndParseJstack() throws 
Exception {
         ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
index d8aaa2630..0b09f6641 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
@@ -32,6 +32,7 @@ import org.apache.rocketmq.common.message.MessageDecoder;
 import org.apache.rocketmq.common.message.MessageExt;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
 import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.body.TopicList;
@@ -60,11 +61,15 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import java.io.ByteArrayInputStream;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.Base64;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.IntStream;
 
@@ -189,6 +194,34 @@ class RocketMQDLQProviderTest {
                 .containsExactly("order-b");
     }
 
+    @Test
+    void listDLQGroupsShouldReportLastEnqueueTimeAsUtcTest() throws Exception {
+        TopicList topicList = new TopicList();
+        topicList.setTopicList(Set.of(MixAll.DLQ_GROUP_TOPIC_PREFIX + 
"group-a"));
+        when(adminExt.fetchAllTopicList()).thenReturn(topicList);
+        // 2025-07-31T16:00:00Z: the UTC wall clock differs from 
Asia/Shanghai's by eight hours,
+        // so a conversion in the server's default zone cannot pass the 
assertion below.
+        long lastUpdate = LocalDateTime.of(2025, 7, 31, 16, 
0).toInstant(ZoneOffset.UTC).toEpochMilli();
+        TopicStatsTable statsTable = new TopicStatsTable();
+        TopicOffset topicOffset = new TopicOffset();
+        topicOffset.setMinOffset(0);
+        topicOffset.setMaxOffset(5);
+        topicOffset.setLastUpdateTimestamp(lastUpdate);
+        statsTable.getOffsetTable().put(new MessageQueue("%DLQ%group-a", 
"broker-a", 0), topicOffset);
+        when(adminExt.examineTopicStats(anyString())).thenReturn(statsTable);
+
+        TimeZone original = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            List<DLQGroupVO> groups = provider.listDLQGroups("instance-a");
+            assertThat(groups).singleElement()
+                    .extracting(DLQGroupVO::getLastEnqueueTime)
+                    
.isEqualTo(LocalDateTime.ofInstant(Instant.ofEpochMilli(lastUpdate), 
ZoneOffset.UTC));
+        } finally {
+            TimeZone.setDefault(original);
+        }
+    }
+
     @Test
     void listDLQGroupsShouldFilterCaseInsensitivelyTest() throws Exception {
         TopicList topicList = new TopicList();
diff --git a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx 
b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
index 19e048b64..5ec63e8dd 100644
--- a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
@@ -36,8 +36,8 @@ const certs: K8sCertInfo[] = [
     cluster: 'prod-cluster',
     type: 'TLS',
     issuer: 'kubernetes-ca',
-    notBefore: '2026-01-01T00:00:00Z',
-    notAfter: '2027-01-01T00:00:00Z',
+    notBefore: '2026-01-01T00:00:00',
+    notAfter: '2027-01-01T00:00:00',
     status: 'valid',
     daysRemaining: 365,
     san: ['broker.prod.example.com'],
@@ -60,8 +60,8 @@ const certs: K8sCertInfo[] = [
     cluster: 'staging-cluster',
     type: 'TLS',
     issuer: 'kubernetes-ca',
-    notBefore: '2026-01-01T00:00:00Z',
-    notAfter: '2027-01-01T00:00:00Z',
+    notBefore: '2026-01-01T00:00:00',
+    notAfter: '2027-01-01T00:00:00',
     status: 'valid',
     daysRemaining: 365,
     // The backend returns null when SAN is omitted.
@@ -109,6 +109,33 @@ describe('K8sCertsPage', () => {
     expect(screen.queryByText('SAN')).not.toBeInTheDocument();
   });
 
+  it('renders the zoneless backend expiry timestamp in the viewer timezone', 
async () => {
+    // The backend serializes notAfter as a UTC LocalDateTime without a zone
+    // designator. The column must interpret it as UTC and render it in the
+    // viewer's timezone; parsing it as browser-local (Date(value)) silently
+    // shifts the displayed expiry by the viewer's UTC offset.
+    renderPage();
+
+    await screen.findByText('rocketmq-prod-tls');
+
+    const parts = new Intl.DateTimeFormat('en-US', {
+      timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      second: '2-digit',
+      hourCycle: 'h23',
+    }).formatToParts(new Date('2027-01-01T00:00:00Z'));
+    const value = (type: string) => parts.find((part) => part.type === 
type)?.value;
+    const expected =
+      `${value('year')}-${value('month')}-${value('day')} ` +
+      `${value('hour')}:${value('minute')}:${value('second')}`;
+
+    expect(screen.getAllByText(expected).length).toBeGreaterThan(0);
+  });
+
   it('renders and sorts incomplete certificate metadata safely', async () => {
     const user = userEvent.setup();
     renderPage();
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index 91ab2b611..1b633d985 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -38,7 +38,7 @@ import InfoBanner from '../../components/InfoBanner';
 import type { K8sCertInfo } from '../../api/cluster';
 import { listK8sCerts, createK8sCert, deleteK8sCert } from 
'../../services/clusterService';
 import { describeThrownMessage } from '../../utils/apiError';
-import { formatDateTime } from '../../utils/format';
+import { formatUtcDateTime } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
 
 const { Text } = Typography;
@@ -185,7 +185,7 @@ const K8sCertsPage = () => {
       sorter: (a, b) => (Date.parse(a.notAfter ?? '') || 0) - 
(Date.parse(b.notAfter ?? '') || 0),
       render: (iso: string | null) => (
         <Text type="secondary" style={{ fontSize: 14 }}>
-          {formatDateTime(iso)}
+          {formatUtcDateTime(iso, undefined, { zone: false })}
         </Text>
       ),
     },
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 3fb5bbb65..cc31f0e72 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -1240,6 +1240,56 @@ describe('Consumer page', () => {
     ).toBeInTheDocument();
   });
 
+  it('renders the stack capture time in the viewer timezone from the 
offset-less UTC wire format', async () => {
+    // The backend serializes LocalDateTime without an offset, so capturedAt 
arrives as a UTC wall
+    // clock; parsing it as browser-local would shift the shown capture time 
by the viewer's zone.
+    vi.stubEnv('TZ', 'Asia/Shanghai');
+    try {
+      vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+        groupPage([
+          {
+            ...group,
+            instances: [
+              {
+                clientId: 'client-1',
+                protocol: 'Remoting',
+                address: '10.0.0.1:39210',
+                subscribedTopics: ['remote-topic'],
+                lastHeartbeat: '2026-07-23T00:00:00Z',
+                topicLag: {},
+              },
+            ],
+          },
+        ]),
+      );
+      vi.mocked(consumerService.getConsumerStack).mockResolvedValue({
+        groupName: 'remote-cg',
+        clientId: 'client-1',
+        capturedAt: '2026-07-23T00:00:00',
+        threadCount: 1,
+        threads: [
+          {
+            threadName: 'ConsumeMessageThread_1',
+            threadId: 12,
+            state: 'RUNNABLE',
+            blockedTime: 0,
+            waitedTime: 0,
+            stackTrace: 
['org.apache.demo.OrderListener.consume(OrderListener.java:42)'],
+          },
+        ],
+      });
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      renderWithProviders(<ConsumerPage />);
+
+      await user.click(await screen.findByRole('button', { name: /详情/ }));
+      await user.click(await screen.findByRole('button', { name: /线程栈/ }));
+
+      expect(await screen.findByText('2026-07-23 08:00:00 
GMT+8')).toBeInTheDocument();
+    } finally {
+      vi.unstubAllEnvs();
+    }
+  });
+
   it('highlights inconsistent subscriptions and refreshes the check result', 
async () => {
     vi.mocked(consumerService.getConsumerSubscriptions)
       .mockResolvedValueOnce([
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index d29cfeb85..a8d2fdd9d 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -25,6 +25,7 @@ import type { DLQGroup, DLQGroupPage, DLQMessagePage, 
DLQResendResult } from '..
 import { LangProvider } from '../../../i18n/LangContext';
 import * as messageService from '../../../services/messageService';
 import * as instanceService from '../../../services/instanceService';
+import { formatUtcDateTime } from '../../../utils/format';
 import DLQPage, { formatDateTime } from '../dlq';
 
 vi.mock('../../../services/messageService', () => ({
@@ -258,6 +259,25 @@ describe('DLQ page', () => {
     expect(screen.getByText('-')).toBeInTheDocument();
   });
 
+  it('renders the last enqueue time in the viewer timezone from the 
offset-less UTC wire format', async () => {
+    // The backend serializes LocalDateTime without an offset, so the wire 
value is a UTC wall
+    // clock. Parsing it as browser-local (the plain formatDateTime path) 
would shift the displayed
+    // time by the viewer's zone; formatUtcDateTime reads it as UTC and 
converts.
+    vi.stubEnv('TZ', 'Asia/Shanghai');
+    try {
+      vi.mocked(messageService.listDLQGroups).mockResolvedValue(
+        pageOf([{ ...dlqGroup, lastEnqueueTime: '2026-07-24T10:00:00' }]),
+      );
+      renderWithProviders(<DLQPage />);
+
+      const row = (await screen.findByText('cg-order')).closest('tr');
+      if (!row) throw new Error('DLQ group row not found');
+      expect(within(row).getByText('2026-07-24 18:00:00 
GMT+8')).toBeInTheDocument();
+    } finally {
+      vi.unstubAllEnvs();
+    }
+  });
+
   it('opens a message detail drawer with the selected group metadata', async 
() => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
@@ -604,6 +624,22 @@ describe('DLQ page', () => {
     expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
   });
 
+  it('exports the last enqueue time through the UTC formatter instead of the 
raw wire value', async () => {
+    
vi.mocked(messageService.listDLQGroups).mockResolvedValue(pageOf([dlqGroup]));
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<DLQPage />);
+
+    const row = (await screen.findByText('cg-order')).closest('tr');
+    if (!row) throw new Error('DLQ group row not found');
+    await user.click(within(row).getByRole('checkbox'));
+    await user.click(screen.getByRole('button', { name: /批量导出/ }));
+
+    const blob = createObjectURL.mock.calls[0][0] as Blob;
+    const csv = await blob.text();
+    expect(csv).toContain(formatUtcDateTime(dlqGroup.lastEnqueueTime));
+    expect(csv).not.toContain(dlqGroup.lastEnqueueTime as string);
+  });
+
   it('neutralizes formulas hidden behind a leading line feed in CSV summary 
exports', async () => {
     vi.mocked(messageService.listDLQGroups).mockResolvedValue(
       pageOf([
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 01a5577cf..971fcd6ee 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -68,7 +68,7 @@ import PageHeader from '../../components/PageHeader';
 import { InstanceSelect } from '../../components/InstanceSelect';
 import { useLang } from '../../i18n/LangContext';
 import { TOPIC_TYPE_MAP, PROTOCOL_MAP } from '../../constants/theme';
-import { formatDateTime, formatDelay } from '../../utils/format';
+import { formatDateTime, formatDelay, formatUtcDateTime } from 
'../../utils/format';
 import type {
   ConsumerGroup,
   ConsumerInstance,
@@ -2241,7 +2241,7 @@ const ConsumerPageContent = ({
               </Text>
             </Descriptions.Item>
             <Descriptions.Item label="采集时间">
-              {selectedStack?.capturedAt ? 
formatDateTime(selectedStack.capturedAt) : '-'}
+              {selectedStack?.capturedAt ? 
formatUtcDateTime(selectedStack.capturedAt) : '-'}
             </Descriptions.Item>
             <Descriptions.Item label="线程数">{selectedStack?.threadCount ?? 
0}</Descriptions.Item>
           </Descriptions>
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 9062380b8..563acca1c 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -50,6 +50,7 @@ import {
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 import { buildCsv, downloadBlob, downloadCsv, type CsvColumn } from 
'../../utils/download';
 import { describeThrownMessage } from '../../utils/apiError';
+import { formatUtcDateTime } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
 
 const { Text } = Typography;
@@ -73,7 +74,7 @@ const DLQ_EXPORT_COLUMNS: CsvColumn<DLQGroup>[] = [
   { header: 'Message Count', value: (group) => group.messageCount },
   { header: 'Retry Count', value: (group) => group.retryCount },
   { header: 'Status', value: (group) => group.status },
-  { header: 'Last Enqueue Time', value: (group) => group.lastEnqueueTime },
+  { header: 'Last Enqueue Time', value: (group) => 
formatUtcDateTime(group.lastEnqueueTime) },
 ];
 
 const exportDLQGroups = (groups: DLQGroup[], filename: string) => {
@@ -544,7 +545,7 @@ const DLQPage = () => {
       sorter: (a, b) => (a.lastEnqueueTime || 
'').localeCompare(b.lastEnqueueTime || ''),
       render: (time?: string | null) => (
         <Text type="secondary" style={{ fontSize: 14 }}>
-          {formatDateTime(time)}
+          {formatUtcDateTime(time)}
         </Text>
       ),
     },
@@ -923,7 +924,7 @@ const DLQPage = () => {
                     最近入队时间
                   </Text>
                   <Text style={{ fontFamily: 'monospace' }}>
-                    {formatDateTime(detailGroup.lastEnqueueTime)}
+                    {formatUtcDateTime(detailGroup.lastEnqueueTime)}
                   </Text>
                 </div>
               </Space>

Reply via email to