feat(rocketmq): 新增 RocketMQ 数据源连接与测试发消息支持

This commit is contained in:
Syngnat
2026-06-14 12:19:43 +08:00
parent 0fa8afd517
commit 7a85c30752
31 changed files with 2480 additions and 41 deletions

View File

@@ -33,10 +33,10 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain('type === "elasticsearch"');
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
expect(source).toContain(
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";',
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";',
);
expect(source).toContain(
'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "mqtt" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined}',
'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "rocketmq" || dbType === "mqtt" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined}',
);
expect(source).toContain('label="显示数据库 (留空显示全部)"');
});
@@ -77,6 +77,23 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain('return "fetchSize=1024&timeZone=Asia%2FShanghai";');
});
it('exposes RocketMQ in the create-connection picker with nameserver and topic defaults', () => {
expect(source).toContain("case 'rocketmq':");
expect(source).toContain('return 9876;');
expect(source).toContain('rocketmq: ["rocketmq", "rmq"]');
expect(source).toContain("key: 'rocketmq'");
expect(source).toContain("name: 'RocketMQ'");
expect(source).toContain('dbType === "rocketmq"');
expect(source).toContain("return 'NameServer / Topic / Consumer Group';");
expect(source).toContain('return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
expect(source).toContain('return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
expect(source).toContain('label="默认 Topic可选"');
expect(source).toContain('label={dbType === "rocketmq" ? "Access Key" : "用户名"}');
expect(source).toContain('label={dbType === "rocketmq" ? "Secret Key" : "密码"}');
expect(source).toContain('emptyPlaceholder: dbType === "rocketmq" ? "未开启认证可留空" : "密码"');
expect(source).toContain('retainedLabel: dbType === "rocketmq" ? "已保存 Secret Key" : "已保存密码"');
});
it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => {
expect(source).toContain("case 'mqtt':");
expect(source).toContain('return 1883;');

View File

@@ -385,6 +385,7 @@ const ConnectionModal: React.FC<{
);
const disableLocalBackdropFilter = isMacLikePlatform();
const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single";
const rocketmqTopology = Form.useWatch("rocketmqTopology", form) || "single";
const mqttTopology = Form.useWatch("mqttTopology", form) || "single";
const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single";
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
@@ -420,6 +421,7 @@ const ConnectionModal: React.FC<{
);
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
const isRocketMQ = dbType === "rocketmq";
const isMQTT = dbType === "mqtt";
const isKafka = dbType === "kafka";
const isRabbitMQ = dbType === "rabbitmq";
@@ -1756,6 +1758,49 @@ const ConnectionModal: React.FC<{
};
}
if (type === "rocketmq") {
const defaultPort = getDefaultPortByType(type);
const parsed =
parseMultiHostUri(trimmedUri, "rocketmq") ||
parseMultiHostUri(trimmedUri, "rmq");
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${defaultPort}`,
defaultPort,
);
const topology = String(parsed.params.get("topology") || "")
.trim()
.toLowerCase();
const timeoutValue = Number(parsed.params.get("timeout"));
return {
host: primary?.host || "localhost",
port: primary?.port || defaultPort,
user: parsed.username,
password: parsed.password,
database: parsed.database || "",
rocketmqTopology:
topology === "cluster" || hostList.length > 1 ? "cluster" : "single",
rocketmqHosts: hostList.slice(1),
connectionParams: serializeConnectionParams(parsed.params),
timeout:
Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue))
: undefined,
};
}
if (type === "rabbitmq") {
const defaultPort = getDefaultPortByType(type);
const parsed = parseSingleHostUri(
@@ -2044,6 +2089,9 @@ const ConnectionModal: React.FC<{
if (dbType === "iotdb") {
return "iotdb://root:root@127.0.0.1:6667/root.sg";
}
if (dbType === "rocketmq") {
return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";
}
if (dbType === "mqtt") {
return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";
}
@@ -2108,6 +2156,8 @@ const ConnectionModal: React.FC<{
return "timezone=Asia%2FShanghai";
case "iotdb":
return "fetchSize=1024&timeZone=Asia%2FShanghai";
case "rocketmq":
return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";
case "mqtt":
return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";
case "kafka":
@@ -2236,6 +2286,26 @@ const ConnectionModal: React.FC<{
return `mqtt://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`;
}
if (type === "rocketmq") {
const primary = toAddress(host, port, defaultPort);
const nameservers =
values.rocketmqTopology === "cluster"
? normalizeAddressList(values.rocketmqHosts, defaultPort)
: [];
const allNameServers = normalizeAddressList([primary, ...nameservers], defaultPort);
const params = new URLSearchParams();
if (allNameServers.length > 1 || values.rocketmqTopology === "cluster") {
params.set("topology", "cluster");
}
if (Number.isFinite(timeout) && timeout > 0) {
params.set("timeout", String(timeout));
}
mergeConnectionParams(params, values.connectionParams);
const topicPath = database ? `/${encodeURIComponent(database)}` : "";
const query = params.toString();
return `rocketmq://${encodedAuth}${allNameServers.join(",")}${topicPath}${query ? `?${query}` : ""}`;
}
if (type === "rabbitmq") {
const address = toAddress(host, port, defaultPort);
const params = new URLSearchParams();
@@ -2629,6 +2699,8 @@ const ConnectionModal: React.FC<{
configType === "sphinx"
? normalizedHosts.slice(1)
: [];
const rocketmqHosts =
configType === "rocketmq" ? normalizedHosts.slice(1) : [];
const mqttHosts =
configType === "mqtt" ? normalizedHosts.slice(1) : [];
const kafkaHosts =
@@ -2640,6 +2712,9 @@ const ConnectionModal: React.FC<{
const mysqlIsReplica =
String(config.topology || "").toLowerCase() === "replica" ||
mysqlReplicaHosts.length > 0;
const rocketmqIsCluster =
String(config.topology || "").toLowerCase() === "cluster" ||
rocketmqHosts.length > 0;
const mqttIsCluster =
String(config.topology || "").toLowerCase() === "cluster" ||
mqttHosts.length > 0;
@@ -2718,6 +2793,8 @@ const ConnectionModal: React.FC<{
timeout: resolvedJvmTimeout,
mysqlTopology: mysqlIsReplica ? "replica" : "single",
mysqlReplicaHosts: mysqlReplicaHosts,
rocketmqTopology: rocketmqIsCluster ? "cluster" : "single",
rocketmqHosts: rocketmqHosts,
mqttTopology: mqttIsCluster ? "cluster" : "single",
mqttHosts: mqttHosts,
kafkaTopology: kafkaIsCluster ? "cluster" : "single",
@@ -3714,6 +3791,23 @@ const ConnectionModal: React.FC<{
}
}
if (type === "rocketmq") {
const nameservers =
mergedValues.rocketmqTopology === "cluster"
? normalizeAddressList(mergedValues.rocketmqHosts, defaultPort)
: [];
const allHosts = normalizeAddressList(
[`${primaryHost}:${primaryPort}`, ...nameservers],
defaultPort,
);
if (mergedValues.rocketmqTopology === "cluster" || allHosts.length > 1) {
hosts = allHosts;
topology = "cluster";
} else {
topology = "single";
}
}
if (type === "mongodb") {
mongoSrvEnabled = !!mergedValues.mongoSrv;
const extraHosts =
@@ -3950,6 +4044,7 @@ const ConnectionModal: React.FC<{
includeDatabases: undefined,
includeRedisDatabases: undefined,
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
@@ -3961,6 +4056,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],
@@ -4016,6 +4112,8 @@ const ConnectionModal: React.FC<{
httpTunnelUser: "",
httpTunnelPassword: "",
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
@@ -4026,6 +4124,8 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
@@ -4041,7 +4141,7 @@ const ConnectionModal: React.FC<{
});
} else if (type !== "custom") {
const defaultUser =
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
@@ -4060,6 +4160,7 @@ const ConnectionModal: React.FC<{
httpTunnelUser: "",
httpTunnelPassword: "",
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
@@ -4071,6 +4172,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],
@@ -5189,6 +5291,22 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "rocketmq" &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="database"
label="默认 Topic可选"
help="留空时必须在 SQL 中显式指定 Topic连接参数可继续补充 groupId、namespace、tag、pullBatchSize 与 startOffset。"
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder="例如orders.events" />
</Form.Item>
),
})}
{dbType === "mqtt" &&
renderConfigSectionCard({
sectionKey: "service",
@@ -5295,6 +5413,28 @@ const ConnectionModal: React.FC<{
}),
})}
{isRocketMQ &&
renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: renderChoiceCards({
fieldName: "rocketmqTopology",
value: String(rocketmqTopology),
options: [
{
value: "single",
label: "单 NameServer",
description: "只配置一个 NameServer适合本地或简单环境。",
},
{
value: "cluster",
label: "集群模式",
description: "配置多个 NameServer提高路由发现与故障切换成功率。",
},
],
}),
})}
{isMQTT &&
renderConfigSectionCard({
sectionKey: "connectionMode",
@@ -5337,6 +5477,26 @@ const ConnectionModal: React.FC<{
),
})}
{isRocketMQ &&
rocketmqTopology === "cluster" &&
renderConfigSectionCard({
sectionKey: "replica",
icon: <ClusterOutlined />,
children: (
<Form.Item
name="rocketmqHosts"
label="额外 NameServer 地址"
help="可输入多个 NameServer 地址格式host:port回车确认"
>
<Select
mode="tags"
placeholder="例如10.10.0.12:9876、10.10.0.13:9876"
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
),
})}
{isMQTT &&
mqttTopology === "cluster" &&
renderConfigSectionCard({
@@ -5472,19 +5632,19 @@ const ConnectionModal: React.FC<{
>
<Form.Item
name="user"
label="用户名"
label={dbType === "rocketmq" ? "Access Key" : "用户名"}
rules={
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka" || dbType === "rabbitmq")
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "rocketmq" || dbType === "kafka" || dbType === "rabbitmq")
? []
: [createUriAwareRequiredRule("请输入用户名")]
}
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "mqtt" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined} />
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "rocketmq" || dbType === "mqtt" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined} />
</Form.Item>
<Form.Item
name="password"
label="密码"
label={dbType === "rocketmq" ? "Secret Key" : "密码"}
style={{ marginBottom: 0 }}
>
<Input.Password
@@ -5496,8 +5656,8 @@ const ConnectionModal: React.FC<{
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasPrimaryPassword,
emptyPlaceholder: "密码",
retainedLabel: "已保存密码",
emptyPlaceholder: dbType === "rocketmq" ? "未开启认证可留空" : "密码",
retainedLabel: dbType === "rocketmq" ? "已保存 Secret Key" : "已保存密码",
})}
/>
</Form.Item>
@@ -6402,6 +6562,7 @@ const ConnectionModal: React.FC<{
connectionParams: "",
oceanBaseProtocol: "mysql",
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
@@ -6411,6 +6572,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],

View File

@@ -39,6 +39,13 @@ describe('DatabaseIcons', () => {
expect(markup).toContain('>Io</text>');
});
it('includes RocketMQ in the selectable database icons', () => {
expect(DB_ICON_TYPES).toContain('rocketmq');
expect(getDbIconLabel('rocketmq')).toBe('RocketMQ');
const markup = renderToStaticMarkup(<>{getDbIcon('rocketmq', undefined, 22)}</>);
expect(markup).toContain('>Rm</text>');
});
it('includes MQTT in the selectable database icons', () => {
expect(DB_ICON_TYPES).toContain('mqtt');
expect(getDbIconLabel('mqtt')).toBe('MQTT');

View File

@@ -52,6 +52,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
iris: '#1F6FEB',
tdengine: '#2962FF',
iotdb: '#0F766E',
rocketmq: '#EA580C',
mqtt: '#0EA5A4',
kafka: '#F97316',
rabbitmq: '#FF6B35',
@@ -195,6 +196,9 @@ const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const IoTDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.iotdb} label="Io" />
);
const RocketMQIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.rocketmq} label="Rm" />
);
const MQTTIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.mqtt} label="Mq" />
);
@@ -266,6 +270,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
iris: IrisIcon,
tdengine: TDengineIcon,
iotdb: IoTDBIcon,
rocketmq: RocketMQIcon,
mqtt: MQTTIcon,
kafka: KafkaIcon,
rabbitmq: RabbitMQIcon,
@@ -279,7 +284,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'gaussdb', 'goldendb', 'highgo', 'iris', 'tdengine', 'iotdb', 'mqtt', 'kafka', 'rabbitmq', 'chroma', 'qdrant', 'elasticsearch', 'custom',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'gaussdb', 'goldendb', 'highgo', 'iris', 'tdengine', 'iotdb', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq', 'chroma', 'qdrant', 'elasticsearch', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
@@ -301,7 +306,7 @@ export const getDbIconLabel = (type: string): string => {
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
starrocks: 'StarRocks',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', opengauss: 'OpenGauss', gaussdb: 'GaussDB', goldendb: 'GoldenDB', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', iotdb: 'Apache IoTDB', mqtt: 'MQTT', kafka: 'Kafka', rabbitmq: 'RabbitMQ',
vastbase: 'VastBase', opengauss: 'OpenGauss', gaussdb: 'GaussDB', goldendb: 'GoldenDB', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', iotdb: 'Apache IoTDB', rocketmq: 'RocketMQ', mqtt: 'MQTT', kafka: 'Kafka', rabbitmq: 'RabbitMQ',
chroma: 'Chroma',
qdrant: 'Qdrant',
elasticsearch: 'Elasticsearch',

View File

@@ -14,6 +14,28 @@ import {
const { Text } = Typography;
const { TextArea } = Input;
const ROCKETMQ_DELAY_LEVEL_OPTIONS = [
{ label: '不延时', value: 0 },
{ label: '1 · 1s', value: 1 },
{ label: '2 · 5s', value: 2 },
{ label: '3 · 10s', value: 3 },
{ label: '4 · 30s', value: 4 },
{ label: '5 · 1m', value: 5 },
{ label: '6 · 2m', value: 6 },
{ label: '7 · 3m', value: 7 },
{ label: '8 · 4m', value: 8 },
{ label: '9 · 5m', value: 9 },
{ label: '10 · 6m', value: 10 },
{ label: '11 · 7m', value: 11 },
{ label: '12 · 8m', value: 12 },
{ label: '13 · 9m', value: 13 },
{ label: '14 · 10m', value: 14 },
{ label: '15 · 20m', value: 15 },
{ label: '16 · 30m', value: 16 },
{ label: '17 · 1h', value: 17 },
{ label: '18 · 2h', value: 18 },
];
export type MessagePublishModalProps = {
open: boolean;
connection: SavedConnection | null;
@@ -171,22 +193,48 @@ const MessagePublishModal: React.FC<MessagePublishModalProps> = ({
</Form.Item>
)}
{presentation.showTag && (
<Form.Item
label="Tag可选"
name="tag"
extra="留空表示不过滤或不写入 Tag。"
>
<Input placeholder={presentation.tagPlaceholder} />
</Form.Item>
)}
{presentation.showDelayLevel && (
<Form.Item
label="Delay Level可选"
name="delayLevel"
extra="RocketMQ 使用固定延时级别0 表示立即发送。"
>
<Select options={ROCKETMQ_DELAY_LEVEL_OPTIONS} />
</Form.Item>
)}
{presentation.showKey && (
<Form.Item label="消息 Key可选">
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="keyMode" noStyle>
<Select
style={{ width: 120 }}
options={[
{ label: '文本', value: 'text' },
{ label: 'JSON', value: 'json' },
]}
/>
</Form.Item>
<Form.Item label={presentation.keyLabel}>
{presentation.showKeyMode ? (
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="keyMode" noStyle>
<Select
style={{ width: 120 }}
options={[
{ label: '文本', value: 'text' },
{ label: 'JSON', value: 'json' },
]}
/>
</Form.Item>
<Form.Item name="key" noStyle>
<Input placeholder={presentation.keyPlaceholder} />
</Form.Item>
</Space.Compact>
) : (
<Form.Item name="key" noStyle>
<Input placeholder="可留空JSON 模式请输入一行合法 JSON" />
<Input placeholder={presentation.keyPlaceholder} />
</Form.Item>
</Space.Compact>
)}
</Form.Item>
)}
@@ -208,13 +256,15 @@ const MessagePublishModal: React.FC<MessagePublishModalProps> = ({
<TextArea rows={8} placeholder="请输入消息体" />
</Form.Item>
<Form.Item
label="Headers可选"
name="headers"
extra={'需为 JSON 对象,例如 {"x-source":"gonavi"}。'}
>
<TextArea rows={5} placeholder='{"x-source":"gonavi"}' />
</Form.Item>
{presentation.showHeaders && (
<Form.Item
label="Headers(可选)"
name="headers"
extra={'需为 JSON 对象,例如 {"x-source":"gonavi"}。'}
>
<TextArea rows={5} placeholder='{"x-source":"gonavi"}' />
</Form.Item>
)}
{presentation.showProperties && (
<Form.Item

View File

@@ -17,6 +17,7 @@ export const normalizeDriverType = (value: string): string => {
if (normalized === 'elastic') return 'elasticsearch';
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
if (normalized === 'rocket-mq' || normalized === 'rocket_mq' || normalized === 'apache-rocketmq' || normalized === 'apache_rocketmq' || normalized === 'rmq') return 'rocketmq';
if (normalized === 'apache-iotdb' || normalized === 'apache_iotdb') return 'iotdb';
if (normalized === 'mqtts') return 'mqtt';
if (normalized === 'apache-kafka' || normalized === 'apache_kafka') return 'kafka';

View File

@@ -297,6 +297,20 @@ export const resolveConnectionConfigLayout = (
],
};
}
if (type === 'rocketmq') {
return {
kind: 'generic-sql',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'replica',
'service',
'credentials',
],
};
}
if (type === 'kafka') {
return {
kind: 'generic-sql',

View File

@@ -16,6 +16,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
elasticsearch: ["http", "https"],
chroma: ["http", "https", "chroma"],
qdrant: ["http", "https", "qdrant"],
rocketmq: ["rocketmq", "rmq"],
mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"],
rabbitmq: ["rabbitmq", "http", "https"],
};
@@ -30,6 +31,12 @@ const normalizeConnectionType = (type: string) =>
case "greatdb":
case "gdb":
return "goldendb";
case "rocket-mq":
case "rocket_mq":
case "apache-rocketmq":
case "apache_rocketmq":
case "rmq":
return "rocketmq";
case "mqtts":
return "mqtt";
default:
@@ -167,6 +174,7 @@ export const supportsConnectionParamsForType = (type: string) =>
type === "elasticsearch" ||
type === "chroma" ||
type === "qdrant" ||
type === "rocketmq" ||
type === "mqtt" ||
type === "kafka" ||
type === "rabbitmq";

View File

@@ -64,6 +64,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
{
label: '消息队列',
items: [
{ key: 'rocketmq', name: 'RocketMQ' },
{ key: 'mqtt', name: 'MQTT' },
{ key: 'kafka', name: 'Kafka' },
{ key: 'rabbitmq', name: 'RabbitMQ' },
@@ -124,6 +125,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
return 8000;
case 'qdrant':
return 6333;
case 'rocketmq':
return 9876;
case 'mqtt':
return 1883;
case 'kafka':
@@ -162,6 +165,8 @@ export const getConnectionTypeHint = (type: string): string => {
return 'Collection 浏览、向量搜索和 Payload 过滤';
case 'iotdb':
return 'Storage Group / Device / Timeseries';
case 'rocketmq':
return 'NameServer / Topic / Consumer Group';
case 'mqtt':
return 'Broker / Topic Filter / QoS';
case 'kafka':

View File

@@ -164,6 +164,28 @@ describe('dataSourceCapabilities', () => {
});
});
it('treats RocketMQ as a queryable messaging datasource with manual total count and publish support', () => {
expect(getDataSourceCapabilities({ type: 'rocketmq' })).toMatchObject({
type: 'rocketmq',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
preferManualTotalCount: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'rmq' })).toMatchObject({
type: 'rocketmq',
supportsQueryEditor: true,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
preferManualTotalCount: true,
});
});
it('treats MQTT as a queryable messaging datasource with manual total count and publish support', () => {
expect(getDataSourceCapabilities({ type: 'mqtt' })).toMatchObject({
type: 'mqtt',

View File

@@ -35,6 +35,13 @@ const normalizeDataSourceToken = (raw: string): string => {
case 'qdrantdb':
case 'qdrant-db':
return 'qdrant';
case 'rocketmq':
case 'rocket-mq':
case 'rocket_mq':
case 'apache-rocketmq':
case 'apache_rocketmq':
case 'rmq':
return 'rocketmq';
case 'mqtt':
case 'mqtts':
return 'mqtt';
@@ -124,9 +131,9 @@ const COPY_INSERT_TYPES = new Set([
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'mqtt', 'kafka', 'rabbitmq']);
const MESSAGE_PUBLISH_TYPES = new Set(['mqtt', 'kafka', 'rabbitmq']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'mqtt']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const MESSAGE_PUBLISH_TYPES = new Set(['rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'rocketmq', 'mqtt']);
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);

View File

@@ -128,4 +128,45 @@ describe('messagePublish', () => {
bodyMode: 'json',
});
});
it('builds a RocketMQ publish JSON command with tag, keys and delay level', () => {
const result = buildMessagePublishCommand(
{ type: 'rocketmq' },
{
destination: 'orders.events',
key: 'key-a,key-b',
tag: 'TagA',
delayLevel: 3,
bodyMode: 'json',
body: '{"id":1,"event":"created"}',
properties: '{"trace":"trace-1"}',
},
);
const command = JSON.parse(result.commandText);
expect(result.transportLabel).toBe('RocketMQ Topic');
expect(result.destinationLabel).toBe('orders.events');
expect(command).toMatchObject({
publish: 'orders.events',
tag: 'TagA',
delayLevel: 3,
properties: {
trace: 'trace-1',
},
});
expect(command.keys).toEqual(['key-a', 'key-b']);
expect(command.payload).toMatchObject({ id: 1, event: 'created' });
});
it('seeds RocketMQ default publish draft with connection tag and delay level defaults', () => {
expect(createDefaultMessagePublishDraft(
{ type: 'rocketmq', database: 'orders.events', connectionParams: 'tag=TagA&delayLevel=5' },
'',
)).toMatchObject({
destination: 'orders.events',
tag: 'TagA',
delayLevel: 5,
bodyMode: 'json',
});
});
});

View File

@@ -17,6 +17,8 @@ export type MessagePublishDraft = {
routingKey?: string;
qos?: number;
retain?: boolean;
tag?: string;
delayLevel?: number;
keyMode?: MessagePublishValueMode;
key?: string;
bodyMode?: MessagePublishValueMode;
@@ -39,9 +41,16 @@ export type MessagePublishPresentation = {
alertMessage: string;
successHint: string;
showKey: boolean;
showKeyMode: boolean;
keyLabel: string;
keyPlaceholder: string;
showExchange: boolean;
showRoutingKey: boolean;
showHeaders: boolean;
showProperties: boolean;
showTag: boolean;
tagPlaceholder: string;
showDelayLevel: boolean;
showQos: boolean;
showRetain: boolean;
};
@@ -142,6 +151,9 @@ const resolveDefaultDestination = (config: ConnectionLike, explicitDestination:
if (resolvedType === 'kafka') {
return String(config?.database || '').trim();
}
if (resolvedType === 'rocketmq') {
return String(config?.database || params.get('defaultTopic') || params.get('topic') || '').trim();
}
if (resolvedType === 'mqtt') {
return String(config?.database || params.get('defaultTopic') || params.get('topic') || '').trim();
}
@@ -165,9 +177,40 @@ export const getMessagePublishPresentation = (
alertMessage: '当前表单会自动拼装 RabbitMQ publish JSON 命令,并通过 Management API 执行测试发送。',
successHint: '留空 Exchange 时会使用默认交换机并按 Queue 名作为 routing key。',
showKey: false,
showKeyMode: false,
keyLabel: '消息 Key可选',
keyPlaceholder: '',
showExchange: true,
showRoutingKey: true,
showHeaders: true,
showProperties: true,
showTag: false,
tagPlaceholder: '',
showDelayLevel: false,
showQos: false,
showRetain: false,
};
}
if (resolvedType === 'rocketmq') {
return {
transportLabel: 'RocketMQ Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如orders.events',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 RocketMQ publish JSON 命令,并通过 NameServer/Broker 执行测试发送。',
successHint: 'Tag、Keys、Delay Level 与 Properties 会一并写入 RocketMQ 消息属性。',
showKey: true,
showKeyMode: false,
keyLabel: '消息 Keys可选',
keyPlaceholder: '可输入多个 Key使用逗号分隔',
showExchange: false,
showRoutingKey: false,
showHeaders: false,
showProperties: true,
showTag: true,
tagPlaceholder: '例如TagA',
showDelayLevel: true,
showQos: false,
showRetain: false,
};
@@ -182,9 +225,16 @@ export const getMessagePublishPresentation = (
alertMessage: '当前表单会自动拼装 MQTT publish JSON 命令,并直接通过 broker 执行测试发送。',
successHint: 'QoS 与 retain 可单独指定;未填写时沿用当前连接中的默认参数。',
showKey: false,
showKeyMode: false,
keyLabel: '消息 Key可选',
keyPlaceholder: '',
showExchange: false,
showRoutingKey: false,
showHeaders: false,
showProperties: false,
showTag: false,
tagPlaceholder: '',
showDelayLevel: false,
showQos: true,
showRetain: true,
};
@@ -198,9 +248,16 @@ export const getMessagePublishPresentation = (
alertMessage: '当前表单会自动拼装 Kafka publish JSON 命令,并直接调用后端执行测试发送。',
successHint: 'Headers 会作为 Kafka Record Headers 一并发送。',
showKey: true,
showKeyMode: true,
keyLabel: '消息 Key可选',
keyPlaceholder: '可留空JSON 模式请输入一行合法 JSON',
showExchange: false,
showRoutingKey: false,
showHeaders: true,
showProperties: false,
showTag: false,
tagPlaceholder: '',
showDelayLevel: false,
showQos: false,
showRetain: false,
};
@@ -226,6 +283,20 @@ export const createDefaultMessagePublishDraft = (
};
}
if (resolvedType === 'rocketmq') {
const delayLevel = Number(params.get('delayLevel') || params.get('delay_level'));
return {
destination: resolvedDestination,
tag: String(params.get('tag') || params.get('tags') || '').trim(),
delayLevel: Number.isFinite(delayLevel) && delayLevel > 0 ? Math.trunc(delayLevel) : undefined,
key: '',
bodyMode: 'json',
body: '{\n "event": "test",\n "source": "gonavi"\n}',
headers: '',
properties: '{\n "x-source": "gonavi"\n}',
};
}
if (resolvedType === 'mqtt') {
const qosValue = Number(params.get('qos'));
return {
@@ -279,6 +350,43 @@ export const buildMessagePublishCommand = (
};
}
if (resolvedType === 'rocketmq') {
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
};
const keys = String(draft.key || '')
.split(/[,;|\s]+/g)
.map((item) => item.trim())
.filter(Boolean);
if (keys.length > 0) {
command.keys = keys;
}
const tag = String(draft.tag || '').trim();
if (tag) {
command.tag = tag;
}
const delayLevel = Number(draft.delayLevel);
if (Number.isFinite(delayLevel) && delayLevel > 0) {
command.delayLevel = Math.trunc(delayLevel);
}
const properties = parseOptionalJSONObject(draft.properties, 'Properties');
if (properties && Object.keys(properties).length > 0) {
command.properties = properties;
}
return {
commandText: JSON.stringify(command, null, 2),
destinationLabel: destination,
transportLabel: 'RocketMQ Topic',
};
}
if (resolvedType === 'rabbitmq') {
const params = resolveConnectionParams(config);
const bodyMode = normalizeMode(draft.bodyMode, 'json');

View File

@@ -7,6 +7,10 @@ describe('buildTableSelectQuery', () => {
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
});
it('adds a preview limit for RocketMQ topic browsing', () => {
expect(buildTableSelectQuery('rocketmq', 'orders.events')).toBe('SELECT * FROM "orders.events" LIMIT 100;');
});
it('adds a preview limit for Kafka topic browsing', () => {
expect(buildTableSelectQuery('kafka', 'logs.app-1')).toBe('SELECT * FROM "logs.app-1" LIMIT 100;');
});

View File

@@ -5,7 +5,7 @@ export const buildTableSelectQuery = (dbType: string, tableName: string): string
if (!normalizedTableName) {
return 'SELECT * FROM ';
}
if (['mqtt', 'kafka', 'rabbitmq'].includes(String(dbType || '').trim().toLowerCase())) {
if (['rocketmq', 'mqtt', 'kafka', 'rabbitmq'].includes(String(dbType || '').trim().toLowerCase())) {
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)} LIMIT 100;`;
}
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;

View File

@@ -59,6 +59,11 @@ describe('quoteQualifiedIdent', () => {
.toBe('`root`.`sg`.`d1`');
});
it('keeps RocketMQ topic names as one quoted identifier', () => {
expect(quoteQualifiedIdent('rocketmq', 'orders.events.v1'))
.toBe('"orders.events.v1"');
});
it('keeps MQTT topic filters as one quoted identifier', () => {
expect(quoteQualifiedIdent('mqtt', 'devices/+/telemetry.v1'))
.toBe('"devices/+/telemetry.v1"');

View File

@@ -54,7 +54,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
const raw = (ident || '').trim();
if (!raw) return raw;
if (['mqtt', 'kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
if (['rocketmq', 'mqtt', 'kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
return quoteIdentPart(dbType, raw);
}
const parts = splitQualifiedNameSegments(raw).filter(Boolean);

View File

@@ -38,6 +38,8 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
expect(resolveSqlDialect('Apache-IoTDB')).toBe('iotdb');
expect(resolveSqlDialect('custom', 'apache_iotdb')).toBe('iotdb');
expect(resolveSqlDialect('Rocket-MQ')).toBe('rocketmq');
expect(resolveSqlDialect('custom', 'rmq')).toBe('rocketmq');
expect(resolveSqlDialect('MQTTS')).toBe('mqtt');
expect(resolveSqlDialect('custom', 'mqtts')).toBe('mqtt');
expect(resolveSqlDialect('Apache-Kafka')).toBe('kafka');
@@ -77,6 +79,11 @@ describe('sqlDialect', () => {
expect(resolveSqlKeywords('iotdb')).not.toEqual(expect.arrayContaining(['TAGS', 'USING']));
});
it('resolves RocketMQ completion keywords for topic discovery and consume syntax', () => {
expect(resolveSqlKeywords('rocketmq')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
expect(resolveSqlKeywords('rocketmq')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
});
it('resolves MQTT completion keywords for topic discovery and consume syntax', () => {
expect(resolveSqlKeywords('mqtt')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
expect(resolveSqlKeywords('mqtt')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));

View File

@@ -29,6 +29,7 @@ export type SqlDialect =
| 'clickhouse'
| 'tdengine'
| 'iotdb'
| 'rocketmq'
| 'mqtt'
| 'kafka'
| 'rabbitmq'
@@ -138,6 +139,13 @@ export const resolveSqlDialect = (
case 'apache-iotdb':
case 'apache_iotdb':
return 'iotdb';
case 'rocketmq':
case 'rocket-mq':
case 'rocket_mq':
case 'apache-rocketmq':
case 'apache_rocketmq':
case 'rmq':
return 'rocketmq';
case 'mqtt':
case 'mqtts':
return 'mqtt';
@@ -173,6 +181,7 @@ export const resolveSqlDialect = (
if (source.includes('clickhouse')) return 'clickhouse';
if (source.includes('tdengine')) return 'tdengine';
if (source.includes('iotdb')) return 'iotdb';
if (source.includes('rocketmq') || source.includes('rocket-mq') || source.includes('rocket_mq') || source === 'rmq') return 'rocketmq';
if (source.includes('mqtt')) return 'mqtt';
if (source.includes('kafka')) return 'kafka';
if (source.includes('rabbitmq') || source.includes('rabbit-mq') || source.includes('rabbit_mq')) return 'rabbitmq';
@@ -628,6 +637,15 @@ const IOTDB_KEYWORDS = [
'COMPRESSION',
];
const ROCKETMQ_KEYWORDS = [
'SHOW TOPICS',
'DESCRIBE TOPIC',
'CONSUME',
'FROM',
'LIMIT',
'OFFSET',
];
const MQTT_KEYWORDS = [
'SHOW TOPICS',
'DESCRIBE TOPIC',
@@ -672,6 +690,7 @@ export const resolveSqlKeywords = (dbType: string): string[] => {
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
if (dialect === 'iotdb') return unique([...COMMON_KEYWORDS, ...IOTDB_KEYWORDS]);
if (dialect === 'rocketmq') return unique([...COMMON_KEYWORDS, ...ROCKETMQ_KEYWORDS]);
if (dialect === 'mqtt') return unique([...COMMON_KEYWORDS, ...MQTT_KEYWORDS]);
if (dialect === 'kafka') return unique([...COMMON_KEYWORDS, ...KAFKA_KEYWORDS]);
if (dialect === 'rabbitmq') return unique([...COMMON_KEYWORDS, ...RABBITMQ_KEYWORDS]);