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}
+ />
</>
),
},