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 5f92965f0 feat(topic): add send payload preflight (#3064)
5f92965f0 is described below
commit 5f92965f0091d57f269816979df4810fc59e2468
Author: coder999o <[email protected]>
AuthorDate: Fri Sep 4 14:52:30 2026 +0800
feat(topic): add send payload preflight (#3064)
---
.../pages/instance/__tests__/TopicPage.test.tsx | 88 ++++-
web/src/pages/instance/topic.tsx | 211 ++++++++++-
web/src/utils/messagePayloadPreview.test.ts | 256 +++++++++++++
web/src/utils/messagePayloadPreview.ts | 410 +++++++++++++++++++++
4 files changed, 943 insertions(+), 22 deletions(-)
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 8dc37744b..b230c8ef7 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -16,10 +16,10 @@
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from
'vitest';
-import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import { App } from 'antd';
+import { App, Modal } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import type { BrokerRoute, Topic } from '../../../api/metadata';
import { parseMessageProperties } from '../../../utils/messageProperties';
@@ -132,6 +132,13 @@ const getTableBody = () => {
return tableBody as HTMLElement;
};
+const getSendDialog = async () => {
+ const title = await screen.findByText('发送消息到 topic-01');
+ const dialog = title.closest('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ return dialog as HTMLElement;
+};
+
describe('TopicPage', () => {
beforeEach(() => {
mockTopicsList(buildTopics(25));
@@ -175,6 +182,11 @@ describe('TopicPage', () => {
page: 1,
pageSize: 20,
});
+ topicServiceMocks.sendTopicMessage.mockResolvedValue({
+ msgId: 'MSG-0001',
+ sendTime: '2026-01-02T00:00:00Z',
+ offsetMsgId: 'OFFSET-0001',
+ });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 5,
@@ -191,6 +203,8 @@ describe('TopicPage', () => {
});
afterEach(() => {
+ Modal.destroyAll();
+ cleanup();
vi.clearAllMocks();
});
@@ -644,6 +658,76 @@ describe('TopicPage', () => {
});
});
+ it('previews the send payload and submits the normalized properties', async
() => {
+ const user = userEvent.setup();
+ mockTopicsList([buildTopics(1)[0]]);
+ renderWithProviders();
+
+ await user.click(await screen.findByRole('button', { name: /发送/ }));
+ const dialog = await getSendDialog();
+ fireEvent.change(within(dialog).getByLabelText('Tag'), { target: { value:
' paid ' } });
+ fireEvent.change(within(dialog).getByLabelText('Key'), { target: { value:
' order-1 ' } });
+ fireEvent.change(within(dialog).getByLabelText('消息体 Body'), {
+ target: { value: '{"orderId":"order-1","amount":128}' },
+ });
+ await user.click(within(dialog).getByRole('button', { name: /添加属性/ }));
+ fireEvent.change(within(dialog).getByPlaceholderText('属性名'), {
+ target: { value: 'traceId' },
+ });
+ fireEvent.change(within(dialog).getByPlaceholderText('属性值'), {
+ target: { value: 'trace-1' },
+ });
+
+ await waitFor(() =>
expect(within(dialog).getByText('可以发送')).toBeInTheDocument());
+ expect(within(dialog).getByText('JSON Object')).toBeInTheDocument();
+ expect(within(dialog).getByText('Tag paid')).toBeInTheDocument();
+ expect(within(dialog).getByText('Key order-1')).toBeInTheDocument();
+ expect(within(dialog).getByText('traceId=trace-1')).toBeInTheDocument();
+
+ await user.click(within(dialog).getByRole('button', { name: /发\s*送/ }));
+
+ await waitFor(() =>
expect(topicServiceMocks.sendTopicMessage).toHaveBeenCalledTimes(1));
+ expect(topicServiceMocks.sendTopicMessage).toHaveBeenCalledWith({
+ topic: 'topic-01',
+ instanceId: 'instance-proxy-1',
+ tag: 'paid',
+ key: 'order-1',
+ body: '{"orderId":"order-1","amount":128}',
+ properties: { traceId: 'trace-1' },
+ });
+ });
+
+ it('blocks duplicate form properties in the send payload preflight', async
() => {
+ const user = userEvent.setup();
+ mockTopicsList([buildTopics(1)[0]]);
+ renderWithProviders();
+
+ await user.click(await screen.findByRole('button', { name: /发送/ }));
+ const dialog = await getSendDialog();
+ fireEvent.change(within(dialog).getByLabelText('消息体 Body'), {
+ target: { value: '{"event":"created"}' },
+ });
+ await user.click(within(dialog).getByRole('button', { name: /添加属性/ }));
+ await user.click(within(dialog).getByRole('button', { name: /添加属性/ }));
+ const propertyKeys = within(dialog).getAllByPlaceholderText('属性名');
+ const propertyValues = within(dialog).getAllByPlaceholderText('属性值');
+ fireEvent.change(propertyKeys[0], { target: { value: 'traceId' } });
+ fireEvent.change(propertyValues[0], { target: { value: 'first' } });
+ fireEvent.change(propertyKeys[1], { target: { value: 'traceId' } });
+ fireEvent.change(propertyValues[1], { target: { value: 'second' } });
+
+ await waitFor(() =>
expect(within(dialog).getByText('阻止发送')).toBeInTheDocument());
+ expect(within(dialog).getByText('属性名重复')).toBeInTheDocument();
+
expect(within(dialog).getByText('重复属性会覆盖前面的值:traceId')).toBeInTheDocument();
+
+ await user.click(within(dialog).getByRole('button', { name: /发\s*送/ }));
+
+ await waitFor(() =>
+ expect(screen.getByText(/发送前预检未通过:属性名重复/)).toBeInTheDocument(),
+ );
+ expect(topicServiceMocks.sendTopicMessage).not.toHaveBeenCalled();
+ });
+
it('renders unavailable Topic consumer metrics distinctly from zero', async
() => {
const user = userEvent.setup();
mockTopicsList([buildTopics(1)[0]]);
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 12d696ee7..8ec7335f8 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -86,7 +86,6 @@ import {
type ResourceImportRow,
} from '../../utils/resourceCsvImport';
import { downloadCsv } from '../../utils/download';
-import { parseMessageProperties } from '../../utils/messageProperties';
import { tableScrollX } from '../../utils/table';
import {
analyzeTopicRoutes,
@@ -94,6 +93,13 @@ import {
type RouteDiagnosticStatus,
type RouteDistribution,
} from '../../utils/topicRouteDiagnostics';
+import {
+ analyzeMessagePayloadPreview,
+ type MessageBodyFormat,
+ type MessagePayloadIssue,
+ type MessagePayloadPreviewStatus,
+ type MessagePropertyInput,
+} from '../../utils/messagePayloadPreview';
const { Text } = Typography;
@@ -150,6 +156,15 @@ const TOPIC_TYPE_CARDS = [
// ─── Perm label ───────────────────────────────────────────────────
const PERM_LABEL: Record<string, string> = { RW: '读写', RO: '只读', WO: '只写' };
+type SendMessageFormValues = {
+ topic: string;
+ tag?: string;
+ key?: string;
+ body: string;
+ propsText?: string;
+ properties?: MessagePropertyInput[];
+};
+
const visibleTopics = (
topics: Topic[],
selectedInstanceId: string | undefined,
@@ -299,6 +314,35 @@ const ISSUE_SEVERITY_COLOR:
Record<RouteDiagnosticIssue['severity'], string> = {
const formatPercent = (value: number) => `${value.toFixed(value % 1 === 0 ? 0
: 1)}%`;
+const formatBytes = (bytes: number): string => {
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`;
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB`;
+ return `${bytes} B`;
+};
+
+const BODY_FORMAT_LABEL: Record<MessageBodyFormat, string> = {
+ empty: '空 Body',
+ 'json-object': 'JSON Object',
+ 'json-array': 'JSON Array',
+ 'json-scalar': 'JSON 标量',
+ 'plain-text': '文本',
+};
+
+const PAYLOAD_STATUS_META: Record<
+ MessagePayloadPreviewStatus,
+ { label: string; color: string; alertType: 'success' | 'warning' | 'error' }
+> = {
+ ready: { label: '可以发送', color: 'success', alertType: 'success' },
+ warning: { label: '建议检查', color: 'warning', alertType: 'warning' },
+ error: { label: '阻止发送', color: 'error', alertType: 'error' },
+};
+
+const PAYLOAD_ISSUE_COLOR: Record<MessagePayloadIssue['severity'], string> = {
+ info: 'blue',
+ warning: 'warning',
+ error: 'error',
+};
+
// ═══════════════════════════════════════════════════════════════════
const TopicPage = () => {
const { t } = useLang();
@@ -344,6 +388,12 @@ const TopicPage = () => {
const [sending, setSending] = useState(false);
const [sendForm] = Form.useForm();
const [propsMode, setPropsMode] = useState<'form' | 'text'>('form');
+ const sendTagValue = Form.useWatch('tag', sendForm);
+ const sendKeyValue = Form.useWatch('key', sendForm);
+ const sendBodyValue = Form.useWatch('body', sendForm);
+ const sendPropsTextValue = Form.useWatch('propsText', sendForm);
+ const sendPropertiesValue = Form.useWatch('properties', sendForm) as
+ MessagePropertyInput[] | undefined;
const { modal } = App.useApp();
const importInputRef = useRef<HTMLInputElement>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
@@ -359,6 +409,28 @@ const TopicPage = () => {
const consumersRequestIdRef = useRef(0);
const createInFlightRef = useRef(false);
+ const sendPayloadPreview = useMemo(
+ () =>
+ analyzeMessagePayloadPreview({
+ topic: sendTopic?.name,
+ tag: sendTagValue,
+ key: sendKeyValue,
+ body: sendBodyValue,
+ propsMode,
+ propsText: sendPropsTextValue,
+ properties: sendPropertiesValue,
+ }),
+ [
+ propsMode,
+ sendBodyValue,
+ sendKeyValue,
+ sendPropertiesValue,
+ sendPropsTextValue,
+ sendTagValue,
+ sendTopic?.name,
+ ],
+ );
+
useEffect(() => {
if (!selectedInstanceId) {
topicRequestIdRef.current += 1;
@@ -1184,9 +1256,102 @@ const TopicPage = () => {
},
];
+ const renderPayloadIssues = (issues: MessagePayloadIssue[]) => {
+ if (issues.length === 0) {
+ return <Text type="secondary">未发现阻止发送的问题</Text>;
+ }
+ return (
+ <Space direction="vertical" size={6} style={{ width: '100%' }}>
+ {issues.map((item, index) => (
+ <Flex
+ key={`${item.code}-${item.names?.join(',') ?? index}`}
+ align="flex-start"
+ gap={8}
+ wrap="nowrap"
+ >
+ <Tag color={PAYLOAD_ISSUE_COLOR[item.severity]} style={{
marginTop: 1 }}>
+ {item.severity === 'error' ? '阻止' : item.severity === 'warning'
? '关注' : '提示'}
+ </Tag>
+ <div style={{ minWidth: 0 }}>
+ <Text strong>{item.title}</Text>
+ <Text type="secondary" style={{ display: 'block' }}>
+ {item.description}
+ </Text>
+ </div>
+ </Flex>
+ ))}
+ </Space>
+ );
+ };
+
+ const renderSendPayloadPreview = () => {
+ const statusMeta = PAYLOAD_STATUS_META[sendPayloadPreview.status];
+ const propertyPreview = sendPayloadPreview.propertyEntries.slice(0, 6);
+ const hiddenPropertyCount = sendPayloadPreview.propertyEntries.length -
propertyPreview.length;
+
+ return (
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
+ <Alert
+ showIcon
+ type={statusMeta.alertType}
+ message={
+ <Flex gap={8} align="center" wrap>
+ <span>发送前预检</span>
+ <Tag color={statusMeta.color}>{statusMeta.label}</Tag>
+
<Tag>{BODY_FORMAT_LABEL[sendPayloadPreview.summary.bodyFormat]}</Tag>
+ </Flex>
+ }
+ description={
+ sendPayloadPreview.blockingIssues.length > 0
+ ? `发现 ${sendPayloadPreview.blockingIssues.length} 个阻止发送的问题。`
+ : '将按下方摘要发送到 RocketMQ,发送前可继续调整 Body、Tag、Key 和自定义属性。'
+ }
+ />
+
+ <Flex gap={8} wrap>
+ <Tag>Body {formatBytes(sendPayloadPreview.summary.bodyBytes)}</Tag>
+ <Tag>属性 {sendPayloadPreview.summary.propertyCount}</Tag>
+ <Tag>属性大小
{formatBytes(sendPayloadPreview.summary.propertyBytes)}</Tag>
+ <Tag color={sendPayloadPreview.normalized.tag ? 'blue' : undefined}>
+ Tag {sendPayloadPreview.normalized.tag || '-'}
+ </Tag>
+ <Tag color={sendPayloadPreview.normalized.key ? 'blue' : undefined}>
+ Key {sendPayloadPreview.normalized.key || '-'}
+ </Tag>
+ </Flex>
+
+ <Descriptions bordered size="small" column={1}>
+ <Descriptions.Item label="Topic">
+ <Text code>{sendPayloadPreview.normalized.topic || sendTopic?.name
|| '-'}</Text>
+ </Descriptions.Item>
+ <Descriptions.Item label="Body 类型">
+ {BODY_FORMAT_LABEL[sendPayloadPreview.summary.bodyFormat]} /{' '}
+ {formatBytes(sendPayloadPreview.summary.bodyBytes)}
+ </Descriptions.Item>
+ <Descriptions.Item label="自定义属性">
+ {propertyPreview.length === 0 ? (
+ <Text type="secondary">无</Text>
+ ) : (
+ <Space size={[4, 4]} wrap>
+ {propertyPreview.map((entry) => (
+ <Tag key={entry.key} color={entry.reserved ? 'warning' :
undefined}>
+ {entry.key}={entry.value || '""'}
+ </Tag>
+ ))}
+ {hiddenPropertyCount > 0 && <Tag>+{hiddenPropertyCount}</Tag>}
+ </Space>
+ )}
+ </Descriptions.Item>
+ </Descriptions>
+
+ {renderPayloadIssues(sendPayloadPreview.issues)}
+ </Space>
+ );
+ };
+
// ─── Send message modal submit ────────────────────────────────
const handleSend = async () => {
- let values;
+ let values: SendMessageFormValues;
try {
values = await sendForm.validateFields();
} catch {
@@ -1195,27 +1360,28 @@ const TopicPage = () => {
}
setSending(true);
try {
- // Build properties: batch-paste text mode or key-value form rows
- let props: Record<string, string> = {};
- if (propsMode === 'text') {
- const parsed = parseMessageProperties(values.propsText || '');
- if (parsed.errors.length > 0) {
- message.error(`消息属性格式错误:${parsed.errors.join(';')}`);
- return;
- }
- props = parsed.properties;
- } else if (values.properties && Array.isArray(values.properties)) {
- values.properties.forEach((p: { key?: string; value?: string }) => {
- if (p.key) props[p.key] = p.value || '';
- });
+ const payloadPreview = analyzeMessagePayloadPreview({
+ topic: values.topic,
+ tag: values.tag,
+ key: values.key,
+ body: values.body,
+ propsMode,
+ propsText: values.propsText,
+ properties: values.properties,
+ });
+ if (payloadPreview.blockingIssues.length > 0) {
+ message.error(
+ `发送前预检未通过:${payloadPreview.blockingIssues.map((item) =>
item.title).join(';')}`,
+ );
+ return;
}
const result = await sendTopicMessage({
- topic: values.topic,
+ topic: payloadPreview.normalized.topic,
instanceId: selectedInstanceId || undefined,
- tag: values.tag || undefined,
- key: values.key || undefined,
- body: values.body,
- properties: props,
+ tag: payloadPreview.normalized.tag,
+ key: payloadPreview.normalized.key,
+ body: payloadPreview.normalized.body,
+ properties: payloadPreview.properties,
});
// Keep the modal open for consecutive sends
message.success(`消息发送成功!MsgId: ${result.msgId}`);
@@ -1787,6 +1953,11 @@ const TopicPage = () => {
)}
</Form.List>
)}
+
+ <Divider style={{ margin: '20px 0 16px' }} orientation="left" plain>
+ 发送前预检
+ </Divider>
+ {renderSendPayloadPreview()}
</Form>
</Modal>
diff --git a/web/src/utils/messagePayloadPreview.test.ts
b/web/src/utils/messagePayloadPreview.test.ts
new file mode 100644
index 000000000..813f56d43
--- /dev/null
+++ b/web/src/utils/messagePayloadPreview.test.ts
@@ -0,0 +1,256 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import {
+ analyzeMessagePayloadPreview,
+ buildMessagePropertiesFromRows,
+ isReservedMessageProperty,
+} from './messagePayloadPreview';
+
+describe('messagePayloadPreview', () => {
+ it('summarizes a ready JSON object payload with normalized tag and key', ()
=> {
+ const preview = analyzeMessagePayloadPreview({
+ topic: ' orders-topic ',
+ tag: ' paid ',
+ key: ' order-1 ',
+ body: '{"orderId":"order-1","amount":128}',
+ propsMode: 'form',
+ properties: [
+ { key: 'traceId', value: 'trace-1' },
+ { key: 'tenant', value: 'retail' },
+ ],
+ });
+
+ expect(preview.status).toBe('ready');
+ expect(preview.blockingIssues).toEqual([]);
+ expect(preview.normalized).toEqual({
+ topic: 'orders-topic',
+ tag: 'paid',
+ key: 'order-1',
+ body: '{"orderId":"order-1","amount":128}',
+ });
+ expect(preview.properties).toEqual({ traceId: 'trace-1', tenant: 'retail'
});
+ expect(preview.summary.bodyFormat).toBe('json-object');
+ expect(preview.summary.propertyCount).toBe(2);
+ expect(preview.issues.map((issue) => issue.code)).toEqual(['TRIMMED_TAG',
'TRIMMED_KEY']);
+ });
+
+ it('reports empty bodies as blocking issues', () => {
+ const preview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: ' ',
+ propsMode: 'form',
+ properties: [],
+ });
+
+ expect(preview.status).toBe('error');
+ expect(preview.summary.bodyFormat).toBe('empty');
+ expect(preview.blockingIssues).toHaveLength(1);
+ expect(preview.blockingIssues[0]).toMatchObject({
+ code: 'EMPTY_BODY',
+ field: 'body',
+ severity: 'error',
+ });
+ });
+
+ it('flags default RocketMQ body size overflow as a blocking issue', () => {
+ const preview = analyzeMessagePayloadPreview(
+ {
+ topic: 'orders-topic',
+ body: 'rocketmq',
+ propsMode: 'form',
+ properties: [],
+ },
+ { maxBodyBytes: 4 },
+ );
+
+ expect(preview.status).toBe('error');
+ expect(preview.blockingIssues.map((issue) =>
issue.code)).toEqual(['BODY_SIZE_LIMIT']);
+ expect(preview.summary.bodyBytes).toBe(8);
+ });
+
+ it('keeps plain text sendable while describing the body format', () => {
+ const preview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: 'plain event body',
+ propsMode: 'form',
+ properties: [],
+ });
+
+ expect(preview.status).toBe('ready');
+ expect(preview.summary.bodyFormat).toBe('plain-text');
+ expect(preview.issues.map((issue) =>
issue.code)).toEqual(['PLAIN_TEXT_BODY']);
+ });
+
+ it('detects JSON arrays and scalar JSON bodies separately', () => {
+ const arrayPreview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: '[{"id":1}]',
+ propsMode: 'form',
+ properties: [],
+ });
+ const scalarPreview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: '"ready"',
+ propsMode: 'form',
+ properties: [],
+ });
+
+ expect(arrayPreview.summary.bodyFormat).toBe('json-array');
+ expect(arrayPreview.status).toBe('ready');
+ expect(scalarPreview.summary.bodyFormat).toBe('json-scalar');
+ expect(scalarPreview.issues.map((issue) =>
issue.code)).toEqual(['SCALAR_JSON_BODY']);
+ });
+
+ it('reports duplicate form properties before they can overwrite earlier
values', () => {
+ const result = buildMessagePropertiesFromRows([
+ { key: 'traceId', value: 'first' },
+ { key: 'tenant', value: 'demo' },
+ { key: 'traceId', value: 'second' },
+ ]);
+
+ expect(result.entries.map((entry) => [entry.key, entry.value])).toEqual([
+ ['traceId', 'first'],
+ ['tenant', 'demo'],
+ ]);
+ expect(result.issues).toHaveLength(1);
+ expect(result.issues[0]).toMatchObject({
+ code: 'DUPLICATE_PROPERTY_KEY',
+ severity: 'error',
+ names: ['traceId'],
+ });
+ });
+
+ it('reports form property values that do not have a key', () => {
+ const result = buildMessagePropertiesFromRows([{ key: ' ', value:
'orphan-value' }]);
+
+ expect(result.entries).toEqual([]);
+ expect(result.issues[0]).toMatchObject({
+ code: 'EMPTY_PROPERTY_KEY',
+ severity: 'error',
+ });
+ });
+
+ it('ignores sparse form property rows emitted by dynamic form lists', () => {
+ const result = buildMessagePropertiesFromRows([
+ undefined as unknown as { key?: string; value?: string },
+ { key: 'traceId', value: 'trace-1' },
+ ]);
+
+ expect(result.entries.map((entry) => [entry.key, entry.value])).toEqual([
+ ['traceId', 'trace-1'],
+ ]);
+ expect(result.issues).toEqual([]);
+ });
+
+ it('uses the batch text parser and preserves values containing equals
signs', () => {
+ const preview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: '{}',
+ propsMode: 'text',
+ propsText: 'traceId=abc\nsignature=part-a=part-b',
+ });
+
+ expect(preview.status).toBe('ready');
+ expect(preview.properties).toEqual({
+ traceId: 'abc',
+ signature: 'part-a=part-b',
+ });
+ expect(preview.blockingIssues).toEqual([]);
+ });
+
+ it('maps malformed batch property lines to blocking preview issues', () => {
+ const preview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: '{}',
+ propsMode: 'text',
+ propsText: 'traceId=abc\ntenant\ntraceId=duplicate',
+ });
+
+ expect(preview.status).toBe('error');
+ expect(preview.blockingIssues.map((issue) => issue.code)).toEqual([
+ 'INVALID_PROPERTY_FORMAT',
+ 'INVALID_PROPERTY_FORMAT',
+ ]);
+ expect(preview.properties).toEqual({ traceId: 'abc' });
+ });
+
+ it('warns when user properties use names reserved by RocketMQ message
metadata', () => {
+ const preview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: '{}',
+ propsMode: 'form',
+ properties: [
+ { key: 'TAGS', value: 'tag-a' },
+ { key: 'businessKey', value: 'b-1' },
+ ],
+ });
+
+ expect(isReservedMessageProperty('tags')).toBe(true);
+ expect(isReservedMessageProperty('businessKey')).toBe(false);
+ expect(preview.status).toBe('warning');
+ expect(preview.issues.find((issue) => issue.code ===
'RESERVED_PROPERTY_KEY')).toMatchObject({
+ severity: 'warning',
+ names: ['TAGS'],
+ });
+ });
+
+ it('summarizes empty property values and large property sets without
blocking send', () => {
+ const properties = Array.from({ length: 4 }, (_, index) => ({
+ key: `k${index}`,
+ value: index === 0 ? '' : 'v',
+ }));
+ const preview = analyzeMessagePayloadPreview(
+ {
+ topic: 'orders-topic',
+ body: '{}',
+ propsMode: 'form',
+ properties,
+ },
+ { maxProperties: 2, maxPropertyBytes: 6 },
+ );
+
+ expect(preview.status).toBe('warning');
+ expect(preview.blockingIssues).toEqual([]);
+ expect(preview.summary.propertyCount).toBe(4);
+ expect(preview.issues.map((issue) => issue.code)).toEqual([
+ 'EMPTY_PROPERTY_VALUE',
+ 'PROPERTY_COUNT_LIMIT',
+ 'PROPERTY_SIZE_LIMIT',
+ ]);
+ });
+
+ it('preserves prototype-like property names in the normalized property
object', () => {
+ const preview = analyzeMessagePayloadPreview({
+ topic: 'orders-topic',
+ body: '{}',
+ propsMode: 'text',
+ propsText: '__proto__=trace-prototype\nconstructor=trace-constructor',
+ });
+
+ expect(Object.keys(preview.properties)).toEqual(['__proto__',
'constructor']);
+ expect(preview.properties['__proto__']).toBe('trace-prototype');
+ expect(preview.properties['constructor']).toBe('trace-constructor');
+ expect(JSON.parse(JSON.stringify(preview.properties))).toEqual(
+ Object.fromEntries([
+ ['__proto__', 'trace-prototype'],
+ ['constructor', 'trace-constructor'],
+ ]),
+ );
+ });
+});
diff --git a/web/src/utils/messagePayloadPreview.ts
b/web/src/utils/messagePayloadPreview.ts
new file mode 100644
index 000000000..69113abf5
--- /dev/null
+++ b/web/src/utils/messagePayloadPreview.ts
@@ -0,0 +1,410 @@
+/*
+ * 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 { parseMessageProperties } from './messageProperties';
+
+export type MessagePropertyMode = 'form' | 'text';
+export type MessagePayloadPreviewStatus = 'ready' | 'warning' | 'error';
+export type MessagePayloadIssueSeverity = 'info' | 'warning' | 'error';
+export type MessageBodyFormat =
+ 'empty' | 'json-object' | 'json-array' | 'json-scalar' | 'plain-text';
+
+export type MessagePayloadIssueCode =
+ | 'EMPTY_BODY'
+ | 'BODY_SIZE_LIMIT'
+ | 'INVALID_PROPERTY_FORMAT'
+ | 'EMPTY_PROPERTY_KEY'
+ | 'DUPLICATE_PROPERTY_KEY'
+ | 'RESERVED_PROPERTY_KEY'
+ | 'EMPTY_PROPERTY_VALUE'
+ | 'PROPERTY_COUNT_LIMIT'
+ | 'PROPERTY_SIZE_LIMIT'
+ | 'TRIMMED_TAG'
+ | 'TRIMMED_KEY'
+ | 'PLAIN_TEXT_BODY'
+ | 'SCALAR_JSON_BODY';
+
+export interface MessagePropertyInput {
+ key?: string;
+ value?: string;
+}
+
+export interface MessagePayloadIssue {
+ code: MessagePayloadIssueCode;
+ severity: MessagePayloadIssueSeverity;
+ title: string;
+ description: string;
+ field?: 'body' | 'tag' | 'key' | 'properties';
+ names?: string[];
+}
+
+export interface MessagePayloadPreviewInput {
+ topic?: string;
+ tag?: string;
+ key?: string;
+ body?: string;
+ propsMode: MessagePropertyMode;
+ propsText?: string;
+ properties?: MessagePropertyInput[];
+}
+
+export interface MessagePayloadPreviewOptions {
+ maxBodyBytes?: number;
+ maxProperties?: number;
+ maxPropertyBytes?: number;
+}
+
+export interface MessagePropertyPreviewEntry {
+ key: string;
+ value: string;
+ keyBytes: number;
+ valueBytes: number;
+ reserved: boolean;
+}
+
+export interface MessagePayloadPreview {
+ status: MessagePayloadPreviewStatus;
+ issues: MessagePayloadIssue[];
+ blockingIssues: MessagePayloadIssue[];
+ normalized: {
+ topic: string;
+ tag?: string;
+ key?: string;
+ body: string;
+ };
+ properties: Record<string, string>;
+ propertyEntries: MessagePropertyPreviewEntry[];
+ summary: {
+ bodyBytes: number;
+ tagBytes: number;
+ keyBytes: number;
+ propertyCount: number;
+ propertyBytes: number;
+ bodyFormat: MessageBodyFormat;
+ maxBodyBytes: number;
+ maxProperties: number;
+ maxPropertyBytes: number;
+ };
+}
+
+export const DEFAULT_MAX_MESSAGE_BODY_BYTES = 4 * 1024 * 1024;
+export const DEFAULT_MAX_MESSAGE_PROPERTIES = 32;
+export const DEFAULT_MAX_MESSAGE_PROPERTY_BYTES = 16 * 1024;
+
+const RESERVED_PROPERTY_NAMES = new Set([
+ 'TAGS',
+ 'KEYS',
+ 'UNIQ_KEY',
+ 'WAIT',
+ 'DELAY',
+ 'RETRY_TOPIC',
+ 'REAL_TOPIC',
+ 'REAL_QID',
+ 'TRAN_MSG',
+ 'PGROUP',
+]);
+
+const textBytes = (value?: string): number => new TextEncoder().encode(value
?? '').length;
+
+const normalizeText = (value?: string): string => value?.trim() ?? '';
+
+const issue = (
+ code: MessagePayloadIssueCode,
+ severity: MessagePayloadIssueSeverity,
+ title: string,
+ description: string,
+ field?: MessagePayloadIssue['field'],
+ names?: string[],
+): MessagePayloadIssue => ({
+ code,
+ severity,
+ title,
+ description,
+ field,
+ names,
+});
+
+const bodyFormat = (body: string): MessageBodyFormat => {
+ const trimmed = body.trim();
+ if (!trimmed) return 'empty';
+
+ try {
+ const parsed = JSON.parse(trimmed) as unknown;
+ if (Array.isArray(parsed)) return 'json-array';
+ if (parsed !== null && typeof parsed === 'object') return 'json-object';
+ return 'json-scalar';
+ } catch {
+ return 'plain-text';
+ }
+};
+
+const fromEntries = (entries: MessagePropertyPreviewEntry[]): Record<string,
string> =>
+ Object.fromEntries(entries.map((entry) => [entry.key, entry.value]));
+
+export const isReservedMessageProperty = (key: string): boolean =>
+ RESERVED_PROPERTY_NAMES.has(key.trim().toUpperCase());
+
+export const buildMessagePropertiesFromRows = (
+ rows: MessagePropertyInput[] = [],
+): { entries: MessagePropertyPreviewEntry[]; issues: MessagePayloadIssue[] }
=> {
+ const entries: MessagePropertyPreviewEntry[] = [];
+ const issues: MessagePayloadIssue[] = [];
+ const seen = new Map<string, string>();
+ const duplicates = new Set<string>();
+
+ rows.forEach((row) => {
+ if (!row) return;
+ const rawKey = row.key ?? '';
+ const rawValue = row.value ?? '';
+ const key = rawKey.trim();
+ const value = rawValue.trim();
+
+ if (!key && !value) return;
+ if (!key) {
+ issues.push(
+ issue(
+ 'EMPTY_PROPERTY_KEY',
+ 'error',
+ '属性名不能为空',
+ `属性值“${value}”缺少对应属性名。`,
+ 'properties',
+ ),
+ );
+ return;
+ }
+
+ if (seen.has(key)) {
+ duplicates.add(key);
+ return;
+ }
+
+ seen.set(key, value);
+ entries.push({
+ key,
+ value,
+ keyBytes: textBytes(key),
+ valueBytes: textBytes(value),
+ reserved: isReservedMessageProperty(key),
+ });
+ });
+
+ if (duplicates.size > 0) {
+ issues.push(
+ issue(
+ 'DUPLICATE_PROPERTY_KEY',
+ 'error',
+ '属性名重复',
+ `重复属性会覆盖前面的值:${[...duplicates].sort().join(', ')}`,
+ 'properties',
+ [...duplicates].sort(),
+ ),
+ );
+ }
+
+ return { entries, issues };
+};
+
+const buildMessagePropertiesFromText = (
+ text: string,
+): { entries: MessagePropertyPreviewEntry[]; issues: MessagePayloadIssue[] }
=> {
+ const parsed = parseMessageProperties(text);
+ const entries = Object.entries(parsed.properties).map(([key, value]) => ({
+ key,
+ value,
+ keyBytes: textBytes(key),
+ valueBytes: textBytes(value),
+ reserved: isReservedMessageProperty(key),
+ }));
+
+ return {
+ entries,
+ issues: parsed.errors.map((errorText) =>
+ issue('INVALID_PROPERTY_FORMAT', 'error', '属性格式错误', errorText,
'properties'),
+ ),
+ };
+};
+
+export const analyzeMessagePayloadPreview = (
+ input: MessagePayloadPreviewInput,
+ options: MessagePayloadPreviewOptions = {},
+): MessagePayloadPreview => {
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_MESSAGE_BODY_BYTES;
+ const maxProperties = options.maxProperties ??
DEFAULT_MAX_MESSAGE_PROPERTIES;
+ const maxPropertyBytes = options.maxPropertyBytes ??
DEFAULT_MAX_MESSAGE_PROPERTY_BYTES;
+ const topic = normalizeText(input.topic);
+ const tag = normalizeText(input.tag);
+ const key = normalizeText(input.key);
+ const body = input.body ?? '';
+ const normalizedBody = body;
+ const issues: MessagePayloadIssue[] = [];
+
+ if (input.tag && input.tag !== tag) {
+ issues.push(
+ issue(
+ 'TRIMMED_TAG',
+ 'info',
+ 'Tag 会去除首尾空白',
+ '发送时会使用去除首尾空白后的 Tag。',
+ 'tag',
+ ),
+ );
+ }
+ if (input.key && input.key !== key) {
+ issues.push(
+ issue(
+ 'TRIMMED_KEY',
+ 'info',
+ 'Key 会去除首尾空白',
+ '发送时会使用去除首尾空白后的 Key。',
+ 'key',
+ ),
+ );
+ }
+
+ const format = bodyFormat(normalizedBody);
+ const bodyBytes = textBytes(normalizedBody);
+ if (format === 'empty') {
+ issues.push(issue('EMPTY_BODY', 'error', '消息体为空', '发送消息必须提供 Body。',
'body'));
+ } else if (bodyBytes > maxBodyBytes) {
+ issues.push(
+ issue(
+ 'BODY_SIZE_LIMIT',
+ 'error',
+ '消息体超过默认上限',
+ `当前 Body 为 ${bodyBytes} bytes,超过 ${maxBodyBytes} bytes。`,
+ 'body',
+ ),
+ );
+ } else if (format === 'plain-text') {
+ issues.push(
+ issue(
+ 'PLAIN_TEXT_BODY',
+ 'info',
+ 'Body 不是 JSON',
+ 'RocketMQ 支持文本消息,当前 Body 会按原始文本发送。',
+ 'body',
+ ),
+ );
+ } else if (format === 'json-scalar') {
+ issues.push(
+ issue(
+ 'SCALAR_JSON_BODY',
+ 'info',
+ 'Body 是 JSON 标量',
+ '当前 Body 是合法 JSON,但不是对象或数组。',
+ 'body',
+ ),
+ );
+ }
+
+ const propertyResult =
+ input.propsMode === 'text'
+ ? buildMessagePropertiesFromText(input.propsText ?? '')
+ : buildMessagePropertiesFromRows(input.properties);
+ issues.push(...propertyResult.issues);
+
+ const reservedNames = propertyResult.entries
+ .filter((entry) => entry.reserved)
+ .map((entry) => entry.key)
+ .sort();
+ if (reservedNames.length > 0) {
+ issues.push(
+ issue(
+ 'RESERVED_PROPERTY_KEY',
+ 'warning',
+ '属性名可能与系统属性冲突',
+ `建议改用业务属性名,避免覆盖或混淆系统属性:${reservedNames.join(', ')}`,
+ 'properties',
+ reservedNames,
+ ),
+ );
+ }
+
+ const emptyValueNames = propertyResult.entries
+ .filter((entry) => entry.value.length === 0)
+ .map((entry) => entry.key)
+ .sort();
+ if (emptyValueNames.length > 0) {
+ issues.push(
+ issue(
+ 'EMPTY_PROPERTY_VALUE',
+ 'info',
+ '存在空属性值',
+ `这些属性会以空字符串发送:${emptyValueNames.join(', ')}`,
+ 'properties',
+ emptyValueNames,
+ ),
+ );
+ }
+
+ const propertyBytes = propertyResult.entries.reduce(
+ (sum, entry) => sum + entry.keyBytes + entry.valueBytes,
+ 0,
+ );
+ if (propertyResult.entries.length > maxProperties) {
+ issues.push(
+ issue(
+ 'PROPERTY_COUNT_LIMIT',
+ 'warning',
+ '属性数量较多',
+ `当前 ${propertyResult.entries.length} 个属性,建议控制在 ${maxProperties} 个以内。`,
+ 'properties',
+ ),
+ );
+ }
+ if (propertyBytes > maxPropertyBytes) {
+ issues.push(
+ issue(
+ 'PROPERTY_SIZE_LIMIT',
+ 'warning',
+ '属性总大小较大',
+ `当前属性约 ${propertyBytes} bytes,建议控制在 ${maxPropertyBytes} bytes 以内。`,
+ 'properties',
+ ),
+ );
+ }
+
+ const blockingIssues = issues.filter((item) => item.severity === 'error');
+ const warningIssues = issues.filter((item) => item.severity === 'warning');
+ const status: MessagePayloadPreviewStatus =
+ blockingIssues.length > 0 ? 'error' : warningIssues.length > 0 ? 'warning'
: 'ready';
+
+ return {
+ status,
+ issues,
+ blockingIssues,
+ normalized: {
+ topic,
+ ...(tag ? { tag } : {}),
+ ...(key ? { key } : {}),
+ body: normalizedBody,
+ },
+ properties: fromEntries(propertyResult.entries),
+ propertyEntries: propertyResult.entries,
+ summary: {
+ bodyBytes,
+ tagBytes: textBytes(tag),
+ keyBytes: textBytes(key),
+ propertyCount: propertyResult.entries.length,
+ propertyBytes,
+ bodyFormat: format,
+ maxBodyBytes,
+ maxProperties,
+ maxPropertyBytes,
+ },
+ };
+};