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 0e86b348c feat(message): show properties in detail and queue browser 
(#5023)
0e86b348c is described below

commit 0e86b348c6ee51da9d1e1fa1b2b0cc9f984651b5
Author: zmuxuny <[email protected]>
AuthorDate: Thu Oct 1 02:26:42 2026 -0700

    feat(message): show properties in detail and queue browser (#5023)
    
    * feat(message): display message properties in explorer views
    
    * test(message): cover property display edge cases
---
 web/src/api/message.ts                             |  1 +
 web/src/components/MessageProperties.tsx           | 67 ++++++++++++++++++++++
 web/src/components/QueueBrowser.tsx                |  5 ++
 .../__tests__/MessageProperties.test.tsx           | 61 ++++++++++++++++++++
 web/src/components/__tests__/QueueBrowser.test.tsx | 55 +++++++++++++++++-
 web/src/i18n/translations.ts                       |  4 ++
 .../pages/instance/__tests__/MessagePage.test.tsx  | 25 ++++++++
 web/src/pages/instance/message.tsx                 |  5 ++
 8 files changed, 220 insertions(+), 3 deletions(-)

diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index 7d9ee46c9..d146dde26 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -19,6 +19,7 @@ export interface MessageRecord {
   storeHost: string;
   reconsumeTimes?: number;
   properties: Record<string, string>;
+  propertiesTruncated?: boolean;
   size: number;
 }
 
diff --git a/web/src/components/MessageProperties.tsx 
b/web/src/components/MessageProperties.tsx
new file mode 100644
index 000000000..a05e73c56
--- /dev/null
+++ b/web/src/components/MessageProperties.tsx
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Alert, theme, Typography } from 'antd';
+import type { MessageRecord } from '../api/message';
+import { useLang } from '../i18n/LangContext';
+
+type MessagePropertiesProps = Pick<MessageRecord, 'properties' | 
'propertiesTruncated'>;
+
+export default function MessageProperties({ properties, propertiesTruncated }: 
MessagePropertiesProps) {
+  const { t } = useLang();
+  const { token } = theme.useToken();
+  const entries = Object.entries(properties ?? {});
+
+  if (entries.length === 0 && !propertiesTruncated) return null;
+
+  return (
+    <section aria-label={t('message.properties')} style={{ marginTop: 16 }}>
+      <Typography.Title level={5} style={{ marginBottom: 8 }}>
+        {t('message.properties')}
+      </Typography.Title>
+      {propertiesTruncated && (
+        <Alert
+          type="warning"
+          showIcon
+          message={t('message.propertiesTruncated')}
+          style={{ marginBottom: entries.length > 0 ? 8 : 0 }}
+        />
+      )}
+      {entries.length > 0 && (
+        <div style={{ maxHeight: 240, overflowY: 'auto' }}>
+          <dl style={{ margin: 0 }}>
+            {entries.map(([key, value]) => (
+              <div
+                key={key}
+                style={{
+                  display: 'grid',
+                  gridTemplateColumns: 'minmax(0, 35%) minmax(0, 1fr)',
+                  gap: 12,
+                  padding: '8px 0',
+                  borderBottom: `1px solid ${token.colorBorderSecondary}`,
+                }}
+              >
+                <dt style={{ fontFamily: token.fontFamilyCode, overflowWrap: 
'anywhere' }}>{key}</dt>
+                <dd style={{ margin: 0, whiteSpace: 'pre-wrap', overflowWrap: 
'anywhere' }}>{value}</dd>
+              </div>
+            ))}
+          </dl>
+        </div>
+      )}
+    </section>
+  );
+}
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index c7252d139..3e4fe7f14 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -34,6 +34,7 @@ import {
 import { CloseOutlined, SearchOutlined } from '@ant-design/icons';
 import type { MessageRecord, QueueOffset } from '../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../api/message';
+import MessageProperties from './MessageProperties';
 
 const { Text, Paragraph } = Typography;
 
@@ -387,6 +388,10 @@ export const QueueBrowserResults = ({ state }: { state: 
QueueBrowserState }) =>
                           </pre>
                         </Card>
                       )}
+                      <MessageProperties
+                        properties={entry.message.properties}
+                        propertiesTruncated={entry.message.propertiesTruncated}
+                      />
                     </>
                   ) : (
                     <Text type="secondary">该 offset 处无消息</Text>
diff --git a/web/src/components/__tests__/MessageProperties.test.tsx 
b/web/src/components/__tests__/MessageProperties.test.tsx
new file mode 100644
index 000000000..e9c9dbdd4
--- /dev/null
+++ b/web/src/components/__tests__/MessageProperties.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { render, screen, within } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { LangProvider } from '../../i18n/LangContext';
+import MessageProperties from '../MessageProperties';
+
+describe('MessageProperties', () => {
+  it('omits an empty section when no properties were truncated', () => {
+    const { container } = render(
+      <LangProvider>
+        <MessageProperties properties={{}} propertiesTruncated={false} />
+      </LangProvider>,
+    );
+
+    expect(container).toBeEmptyDOMElement();
+  });
+
+  it('keeps the truncation warning when the server returned no properties', () 
=> {
+    render(
+      <LangProvider>
+        <MessageProperties properties={{}} propertiesTruncated />
+      </LangProvider>,
+    );
+
+    const section = screen.getByRole('region', { name: '消息属性' });
+    
expect(within(section).getByText('属性过多或单值过长,服务端已截断展示')).toBeInTheDocument();
+    expect(section.querySelector('dl')).not.toBeInTheDocument();
+  });
+
+  it('renders long property text and markup-looking values as literal text', 
() => {
+    const longKey = `trace-${'k'.repeat(300)}`;
+    const longValue = `route-${'v'.repeat(1000)}`;
+    const markup = '<img src=x onerror=alert(1)>';
+    render(
+      <LangProvider>
+        <MessageProperties properties={{ [longKey]: longValue, payload: markup 
}} />
+      </LangProvider>,
+    );
+
+    const section = screen.getByRole('region', { name: '消息属性' });
+    expect(within(section).getByText(longKey)).toBeInTheDocument();
+    expect(within(section).getByText(longValue)).toBeInTheDocument();
+    expect(within(section).getByText(markup)).toBeInTheDocument();
+    expect(section.querySelector('img')).not.toBeInTheDocument();
+  });
+});
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
index bcafa44e1..9431c0b1e 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -15,18 +15,34 @@
  * limitations under the License.
  */
 
-import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import type { MessageRecord, QueueOffset } from '../../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
-import { formatTimeMs, useQueueBrowser } from '../QueueBrowser';
+import { formatTimeMs, QueueBrowserResults, useQueueBrowser } from 
'../QueueBrowser';
 
 vi.mock('../../api/message', () => ({
   getQueueOffsets: vi.fn(),
   pullMessageAtOffset: vi.fn(),
 }));
 
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
 const createDeferred = <T,>() => {
   let resolve!: (value: T) => void;
   const promise = new Promise<T>((promiseResolve) => {
@@ -90,6 +106,19 @@ function QueueBrowserProbe({ instanceId = 'instance-a' }: { 
instanceId?: string
   );
 }
 
+function QueueBrowserResultsProbe() {
+  const state = useQueueBrowser('instance-a');
+  const firstQueue = state.queues[0];
+  return (
+    <div>
+      <button type="button" onClick={() => 
state.setTopic('topic-a')}>topic-a</button>
+      <button type="button" onClick={() => void 
state.loadQueues()}>load</button>
+      <button type="button" onClick={() => firstQueue && void 
state.handlePull(firstQueue)}>pull</button>
+      <QueueBrowserResults state={state} />
+    </div>
+  );
+}
+
 describe('formatTimeMs', () => {
   it('preserves the Unix epoch timestamp', () => {
     expect(formatTimeMs(0)).not.toBe('-');
@@ -272,4 +301,24 @@ describe('QueueBrowser request ownership', () => {
 
     expect(screen.getByLabelText('pulling')).toHaveTextContent('false');
   });
+
+  it('shows the properties of a pulled queue message', async () => {
+    vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+    vi.mocked(pullMessageAtOffset).mockResolvedValue({
+      ...messageRecord('message-with-properties'),
+      properties: { traceId: 'queue-trace-123' },
+      propertiesTruncated: true,
+    });
+    const user = userEvent.setup();
+    render(<QueueBrowserResultsProbe />);
+
+    await user.click(screen.getByRole('button', { name: 'topic-a' }));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await screen.findByText('broker-a');
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+
+    const properties = await screen.findByRole('region', { name: '消息属性' });
+    
expect(within(properties).getByText('queue-trace-123')).toBeInTheDocument();
+    
expect(within(properties).getByText('属性过多或单值过长,服务端已截断展示')).toBeInTheDocument();
+  });
 });
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 2b8f36e65..b65365d2e 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -3082,6 +3082,10 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'message.detail': { zh: '消息详情', en: 'Message Detail' },
   'message.body': { zh: '消息主体', en: 'Message Body' },
   'message.properties': { zh: '消息属性', en: 'Message Properties' },
+  'message.propertiesTruncated': {
+    zh: '属性过多或单值过长,服务端已截断展示',
+    en: 'Some properties were shortened by the server',
+  },
   'message.info': { zh: '消息信息', en: 'Message Info' },
   'message.tracking': { zh: '消息追踪', en: 'Message Tracking' },
   'message.showAll': { zh: '显示全部内容', en: 'Show All Content' },
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx 
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index 9ad05cce8..5a9c02d83 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -376,6 +376,31 @@ describe('Message page query history', () => {
     expect(retryItems[0]).toHaveTextContent('2');
   });
 
+  it('shows message properties and warns when the server shortened them', 
async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    messageServiceMocks.queryMessages.mockResolvedValue([
+      {
+        ...createMessage('MID-PROPERTIES'),
+        properties: { traceId: 'trace-123', route: 'priority' },
+        propertiesTruncated: true,
+      },
+    ]);
+    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-PROPERTIES');
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    await user.click(await screen.findByRole('button', { name: /详情/ }));
+
+    const properties = screen.getByRole('region', { name: '消息属性' });
+    expect(within(properties).getByText('traceId')).toBeInTheDocument();
+    expect(within(properties).getByText('trace-123')).toBeInTheDocument();
+    expect(within(properties).getByText('priority')).toBeInTheDocument();
+    
expect(within(properties).getByText('属性过多或单值过长,服务端已截断展示')).toBeInTheDocument();
+  });
+
   it('loads topic options only for the selected instance', async () => {
     instanceFilterMocks.useInstanceFilter.mockReturnValue({
       selectedInstanceId: 1,
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 019b294e9..2e8608b3f 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -54,6 +54,7 @@ import type { Dayjs } from 'dayjs';
 import PageHeader from '../../components/PageHeader';
 import { InstanceSelect } from '../../components/InstanceSelect';
 import MessageQueryHistoryDrawer from 
'../../components/MessageQueryHistoryDrawer';
+import MessageProperties from '../../components/MessageProperties';
 import {
   useQueueBrowser,
   QueueBrowserControls,
@@ -1016,6 +1017,10 @@ const MessagePageContent = ({
           >
             {formatBody(selectedMsg.body)}
           </Paragraph>
+          <MessageProperties
+            properties={selectedMsg.properties}
+            propertiesTruncated={selectedMsg.propertiesTruncated}
+          />
         </>
       ),
     },

Reply via email to