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);
+}

Reply via email to