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 9cb68fe50 fix(web): render an absent trace or consumption time as a
placeholder (#6929)
9cb68fe50 is described below
commit 9cb68fe50c0c643150ede2ab78329fd51c3052b8
Author: Chstercc <[email protected]>
AuthorDate: Mon Oct 26 20:07:02 2026 +0800
fix(web): render an absent trace or consumption time as a placeholder
(#6929)
* fix(web): remove clear controls from always-applied range pickers
The topic query, the DLQ export and the DLQ resend always send a full
start/end pair, so their pickers cannot represent a cleared value: the
clear icon snapped the value back and silently reused the previous
window. Disable that control on the three pickers and pin the behaviour
with regressions.
* fix(web): render an absent trace or consumption time as a placeholder
Trace nodes and consumer status report 'no time' as 0. The shared
formatTimeMs treats zero as a real timestamp because stored values can
legitimately be the epoch, so both call sites rendered 1970-01-01 for a
record that carried no timestamp at all.
Map zero to the placeholder at the optional-time call sites while
leaving formatTimeMs' epoch handling unchanged.
* refactor(web): move the optional-time formatter next to formatTimeMs
The zero-means-absent mapping is a property of the time formatter, not of
the
message page; keeping it in utils/format lets the next optional-time call
site
reuse it instead of re-deriving the epoch caveat.
---
web/src/pages/instance/__tests__/DLQPage.test.tsx | 14 ++++++
.../pages/instance/__tests__/MessagePage.test.tsx | 56 ++++++++++++++++++++++
web/src/pages/instance/dlq.tsx | 2 +
web/src/pages/instance/message.tsx | 15 +++---
web/src/utils/format.ts | 9 ++++
5 files changed, 90 insertions(+), 6 deletions(-)
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 6fa46b1ee..dc371ed4d 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -1325,4 +1325,18 @@ describe('DLQ page', () => {
expect(await screen.findByText(/1 条无法定位/)).toBeInTheDocument();
expect(screen.queryByText(/重发完成:成功 0 条/)).not.toBeInTheDocument();
});
+ it('offers no clear control on the windows the export and the resend always
send', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ const { container } = renderWithProviders(<DLQPage />);
+
+ // Both windows are always sent to the service, so clearing them could only
+ // snap back to the previous value. Neither picker may offer a clear
control.
+ expect(await screen.findByRole('button', { name: '重投消息' })).toBeEnabled();
+ expect(container.querySelectorAll('.ant-picker-clear')).toHaveLength(0);
+
+ await user.click(screen.getByRole('button', { name: '重投消息' }));
+ const retryDialog = (await
screen.findByText('重投死信消息')).closest('.ant-modal');
+ expect(retryDialog).not.toBeNull();
+ expect((retryDialog as
HTMLElement).querySelectorAll('.ant-picker-clear')).toHaveLength(0);
+ });
});
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index b1377a483..e1188bf63 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -785,6 +785,54 @@ describe('Message page query history', () => {
expect(await screen.findByText('cg-billing')).toBeInTheDocument();
});
+ it('renders a consumption with no recorded time as the placeholder instead
of the Unix epoch', async () => {
+
messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-NO-TIME')]);
+ messageServiceMocks.getMessageTrace.mockResolvedValue({
+ nodes: [
+ {
+ title: 'Consumer 消费',
+ timestamp: 0,
+ costTime: 5,
+ status: 'finish',
+ description: 'consumer record carried no timestamp',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-orders',
+ deliveryStatus: 'pending',
+ consumeTime: 0,
+ retryCount: 0,
+ },
+ ],
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'),
'MID-NO-TIME');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ const row = await screen.findByRole('row', { name: /MID-NO-TIME/ });
+ await user.click(within(row).getByRole('button', { name: /轨迹/ }));
+ const dialog = await screen.findByRole('dialog');
+
+ // A trace node whose record carried no timestamp (0) must read as absent,
+ // not as the Unix epoch.
+ const node = (await within(dialog).findByText('Consumer
消费')).closest('.ant-steps-item');
+ expect(node).not.toBeNull();
+ expect(node).not.toHaveTextContent('1970-01-01');
+ expect(within(node as HTMLElement).getByText('-')).toBeInTheDocument();
+
+ // The same placeholder applies to a consumer group with no consumption
time.
+ await user.click(within(dialog).getByRole('tab', { name: '验证' }));
+ const statusRow = (await
within(dialog).findByText('cg-orders')).closest('tr');
+ expect(statusRow).not.toBeNull();
+ expect(within(statusRow as
HTMLElement).getAllByRole('cell')[2]).toHaveTextContent('-');
+ });
+
it('renders placeholders on the detail panel when the storage location is
unknown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
messageServiceMocks.queryMessages.mockResolvedValue([
@@ -867,4 +915,12 @@ describe('Message page query history', () => {
expect(await screen.findByText('消息体')).toBeInTheDocument();
expect(screen.getAllByText('5.0 GB').length).toBeGreaterThanOrEqual(2);
});
+
+ it('offers no clear control on the query window it always sends', () => {
+ const { container } = renderWithProviders(<MessagePage />);
+
+ // The topic query always carries a start/end pair, so a clear control
would
+ // snap back and silently reuse the previous window. It must not be
offered.
+ expect(container.querySelector('.ant-picker-clear')).toBeNull();
+ });
});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 354b54803..69b69e227 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -754,6 +754,7 @@ const DLQPage = () => {
<RangePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
+ allowClear={false}
value={exportRange}
onChange={(vals) => {
if (vals && vals[0] && vals[1]) {
@@ -885,6 +886,7 @@ const DLQPage = () => {
<RangePicker
showTime
style={{ width: '100%' }}
+ allowClear={false}
value={retryRange}
onChange={(vals) => {
if (vals && vals[0] && vals[1]) {
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index c2ab0091f..75064eaea 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -79,7 +79,7 @@ import { useInstanceFilter } from
'../../hooks/useInstanceFilter';
import useAuthStore from '../../stores/authStore';
import { downloadBlob } from '../../utils/download';
import { describeThrownMessage } from '../../utils/apiError';
-import { formatBytes, formatTimeMs } from '../../utils/format';
+import { formatBytes, formatOptionalTimeMs, formatTimeMs } from
'../../utils/format';
import {
readMessageTraceTopic,
writeMessageTraceTopic,
@@ -982,12 +982,14 @@ const MessagePageContent = ({
title: t('messagePage.consumeTime'),
dataIndex: 'consumeTime',
key: 'consumeTime',
- render: (time: string) =>
- time === '-' ? (
+ render: (time: number | string) => {
+ const formatted = formatOptionalTimeMs(time);
+ return formatted === '-' ? (
<span style={{ color: '#9CA3AF' }}>-</span>
) : (
- <span style={{ fontFamily: 'monospace', fontSize: 14
}}>{formatTimeMs(time)}</span>
- ),
+ <span style={{ fontFamily: 'monospace', fontSize: 14
}}>{formatted}</span>
+ );
+ },
},
{
title: t('messagePage.retryCount'),
@@ -1157,7 +1159,7 @@ const MessagePageContent = ({
description: (
<div style={{ fontSize: 14 }}>
<div style={{ color: '#9CA3AF', fontFamily: 'monospace'
}}>
- {formatTimeMs(node.timestamp)}
+ {formatOptionalTimeMs(node.timestamp)}
</div>
<div style={{ marginTop: 2 }}>{node.description}</div>
<div style={{ color: '#9CA3AF', fontSize: 14 }}>
@@ -1240,6 +1242,7 @@ const MessagePageContent = ({
<RangePicker
showTime
style={{ width: 400 }}
+ allowClear={false}
value={dateRange}
onChange={(vals) => {
if (vals && vals[0] && vals[1]) {
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index b9e96e7c1..1f4f9cc95 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -292,3 +292,12 @@ export function ratioToPercent(value: number): number {
: `${digits}${'0'.repeat(pointIndex - digits.length)}`;
return Number(`${sign}${scaled}`);
}
+
+/**
+ * Trace and consumption records report "no time" as 0. {@link formatTimeMs}
deliberately treats
+ * zero as a real timestamp because stored values can legitimately be the
epoch, so the
+ * optional-time call sites map zero to the placeholder themselves.
+ */
+export function formatOptionalTimeMs(value: number | string | null |
undefined): string {
+ return value === 0 || value === '0' ? '-' : formatTimeMs(value);
+}