diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 1cd1f0a..735b666 100644 --- a/frontend/src/components/ConnectionModal.edit-password.test.tsx +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -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;'); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index e106664..0a65376 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -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: , + children: ( + + + + ), + })} + {dbType === "mqtt" && renderConfigSectionCard({ sectionKey: "service", @@ -5295,6 +5413,28 @@ const ConnectionModal: React.FC<{ }), })} + {isRocketMQ && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + 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: , + children: ( + + + @@ -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: [], diff --git a/frontend/src/components/DatabaseIcons.test.tsx b/frontend/src/components/DatabaseIcons.test.tsx index 9f0b4e6..7cf5f09 100644 --- a/frontend/src/components/DatabaseIcons.test.tsx +++ b/frontend/src/components/DatabaseIcons.test.tsx @@ -39,6 +39,13 @@ describe('DatabaseIcons', () => { expect(markup).toContain('>Io'); }); + 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'); + }); + it('includes MQTT in the selectable database icons', () => { expect(DB_ICON_TYPES).toContain('mqtt'); expect(getDbIconLabel('mqtt')).toBe('MQTT'); diff --git a/frontend/src/components/DatabaseIcons.tsx b/frontend/src/components/DatabaseIcons.tsx index bf55eb3..e842906 100644 --- a/frontend/src/components/DatabaseIcons.tsx +++ b/frontend/src/components/DatabaseIcons.tsx @@ -52,6 +52,7 @@ const DB_DEFAULT_COLORS: Record = { iris: '#1F6FEB', tdengine: '#2962FF', iotdb: '#0F766E', + rocketmq: '#EA580C', mqtt: '#0EA5A4', kafka: '#F97316', rabbitmq: '#FF6B35', @@ -195,6 +196,9 @@ const TDengineIcon: React.FC = ({ size = 16, color }) => ( const IoTDBIcon: React.FC = ({ size = 16, color }) => ( ); +const RocketMQIcon: React.FC = ({ size = 16, color }) => ( + +); const MQTTIcon: React.FC = ({ size = 16, color }) => ( ); @@ -266,6 +270,7 @@ const DB_ICON_MAP: Record> = { iris: IrisIcon, tdengine: TDengineIcon, iotdb: IoTDBIcon, + rocketmq: RocketMQIcon, mqtt: MQTTIcon, kafka: KafkaIcon, rabbitmq: RabbitMQIcon, @@ -279,7 +284,7 @@ const DB_ICON_MAP: Record> = { 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', diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx index e9b379f..85571f2 100644 --- a/frontend/src/components/MessagePublishModal.tsx +++ b/frontend/src/components/MessagePublishModal.tsx @@ -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 = ({ )} + {presentation.showTag && ( + + + + )} + + {presentation.showDelayLevel && ( + + - + + {presentation.showKeyMode ? ( + + + + + + ) : ( - + - + )} )} @@ -208,13 +256,15 @@ const MessagePublishModal: React.FC = ({