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>