diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 429b214..1cd1f0a 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 === "kafka" || type === "rabbitmq") ? "" : "root";', + 'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";', ); expect(source).toContain( - 'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined}', + 'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "mqtt" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined}', ); expect(source).toContain('label="显示数据库 (留空显示全部)"'); }); @@ -77,6 +77,19 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain('return "fetchSize=1024&timeZone=Asia%2FShanghai";'); }); + it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => { + expect(source).toContain("case 'mqtt':"); + expect(source).toContain('return 1883;'); + expect(source).toContain('mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"]'); + expect(source).toContain("key: 'mqtt'"); + expect(source).toContain("name: 'MQTT'"); + expect(source).toContain('dbType === "mqtt"'); + expect(source).toContain("return 'Broker / Topic Filter / QoS';"); + expect(source).toContain('return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";'); + expect(source).toContain('return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";'); + expect(source).toContain('label="默认 Topic / Filter(可选)"'); + }); + it('exposes Kafka in the create-connection picker with broker and topic defaults', () => { expect(source).toContain("case 'kafka':"); expect(source).toContain('return 9092;'); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 538a2d9..e106664 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 mqttTopology = Form.useWatch("mqttTopology", form) || "single"; const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single"; const mongoTopology = Form.useWatch("mongoTopology", form) || "single"; const mongoSrv = Form.useWatch("mongoSrv", form) || false; @@ -419,6 +420,7 @@ const ConnectionModal: React.FC<{ ); const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle"; const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle; + const isMQTT = dbType === "mqtt"; const isKafka = dbType === "kafka"; const isRabbitMQ = dbType === "rabbitmq"; const supportsConnectionParams = supportsConnectionParamsForType(dbType); @@ -1691,6 +1693,69 @@ const ConnectionModal: React.FC<{ }; } + if (type === "mqtt") { + const defaultPort = getDefaultPortByType(type); + const parsed = + parseMultiHostUri(trimmedUri, "mqtt") || + parseMultiHostUri(trimmedUri, "mqtts") || + parseMultiHostUri(trimmedUri, "tcp") || + parseMultiHostUri(trimmedUri, "ssl") || + parseMultiHostUri(trimmedUri, "tls"); + 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 lowerUri = trimmedUri.toLowerCase(); + const tlsEnabled = + lowerUri.startsWith("mqtts://") || + lowerUri.startsWith("ssl://") || + lowerUri.startsWith("tls://") || + normalizeUriBool( + parsed.params.get("tls") || + parsed.params.get("ssl") || + parsed.params.get("useSSL") || + parsed.params.get("use_ssl"), + ); + const skipVerify = normalizeUriBool( + parsed.params.get("skip_verify") || parsed.params.get("skipVerify"), + ); + 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 || "", + useSSL: tlsEnabled, + sslMode: tlsEnabled ? (skipVerify ? "skip-verify" : "required") : "disable", + ...extractSSLPathValuesFromParams(parsed.params, type), + mqttTopology: + topology === "cluster" || hostList.length > 1 ? "cluster" : "single", + mqttHosts: 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( @@ -1979,6 +2044,9 @@ const ConnectionModal: React.FC<{ if (dbType === "iotdb") { return "iotdb://root:root@127.0.0.1:6667/root.sg"; } + if (dbType === "mqtt") { + return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1"; + } if (dbType === "kafka") { return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256"; } @@ -2040,6 +2108,8 @@ const ConnectionModal: React.FC<{ return "timezone=Asia%2FShanghai"; case "iotdb": return "fetchSize=1024&timeZone=Asia%2FShanghai"; + case "mqtt": + return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000"; case "kafka": return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest"; case "rabbitmq": @@ -2136,6 +2206,36 @@ const ConnectionModal: React.FC<{ return `kafka://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`; } + if (type === "mqtt") { + const primary = toAddress(host, port, defaultPort); + const brokers = + values.mqttTopology === "cluster" + ? normalizeAddressList(values.mqttHosts, defaultPort) + : []; + const allBrokers = normalizeAddressList([primary, ...brokers], defaultPort); + const params = new URLSearchParams(); + if (allBrokers.length > 1 || values.mqttTopology === "cluster") { + params.set("topology", "cluster"); + } + if (values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + params.set("tls", "true"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + appendSSLPathParamsForUri(params, type, values); + } + 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 `mqtt://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`; + } + if (type === "rabbitmq") { const address = toAddress(host, port, defaultPort); const params = new URLSearchParams(); @@ -2529,6 +2629,8 @@ const ConnectionModal: React.FC<{ configType === "sphinx" ? normalizedHosts.slice(1) : []; + const mqttHosts = + configType === "mqtt" ? normalizedHosts.slice(1) : []; const kafkaHosts = configType === "kafka" ? normalizedHosts.slice(1) : []; const mongoHosts = @@ -2538,6 +2640,9 @@ const ConnectionModal: React.FC<{ const mysqlIsReplica = String(config.topology || "").toLowerCase() === "replica" || mysqlReplicaHosts.length > 0; + const mqttIsCluster = + String(config.topology || "").toLowerCase() === "cluster" || + mqttHosts.length > 0; const kafkaIsCluster = String(config.topology || "").toLowerCase() === "cluster" || kafkaHosts.length > 0; @@ -2613,6 +2718,8 @@ const ConnectionModal: React.FC<{ timeout: resolvedJvmTimeout, mysqlTopology: mysqlIsReplica ? "replica" : "single", mysqlReplicaHosts: mysqlReplicaHosts, + mqttTopology: mqttIsCluster ? "cluster" : "single", + mqttHosts: mqttHosts, kafkaTopology: kafkaIsCluster ? "cluster" : "single", kafkaHosts: kafkaHosts, mysqlReplicaUser: config.mysqlReplicaUser || "", @@ -3590,6 +3697,23 @@ const ConnectionModal: React.FC<{ } } + if (type === "mqtt") { + const brokers = + mergedValues.mqttTopology === "cluster" + ? normalizeAddressList(mergedValues.mqttHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...brokers], + defaultPort, + ); + if (mergedValues.mqttTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + if (type === "mongodb") { mongoSrvEnabled = !!mergedValues.mongoSrv; const extraHosts = @@ -3826,6 +3950,7 @@ const ConnectionModal: React.FC<{ includeDatabases: undefined, includeRedisDatabases: undefined, mysqlTopology: "single", + mqttTopology: "single", kafkaTopology: "single", redisTopology: "single", mongoTopology: "single", @@ -3836,6 +3961,7 @@ const ConnectionModal: React.FC<{ mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], + mqttHosts: [], kafkaHosts: [], redisHosts: [], redisSentinelMaster: "", @@ -3915,7 +4041,7 @@ const ConnectionModal: React.FC<{ }); } else if (type !== "custom") { const defaultUser = - type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "kafka" || type === "rabbitmq") ? "" : "root"; + type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root"; const sslCapableType = supportsSSLForType(type); setUseSSL(false); setUseHttpTunnel(false); @@ -3934,6 +4060,7 @@ const ConnectionModal: React.FC<{ httpTunnelUser: "", httpTunnelPassword: "", mysqlTopology: "single", + mqttTopology: "single", kafkaTopology: "single", redisTopology: "single", mongoTopology: "single", @@ -3944,6 +4071,7 @@ const ConnectionModal: React.FC<{ mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], + mqttHosts: [], kafkaHosts: [], redisHosts: [], redisSentinelMaster: "", @@ -5061,6 +5189,22 @@ const ConnectionModal: React.FC<{ ), })} + {dbType === "mqtt" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + {dbType === "rabbitmq" && renderConfigSectionCard({ sectionKey: "service", @@ -5151,6 +5295,28 @@ const ConnectionModal: React.FC<{ }), })} + {isMQTT && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "mqttTopology", + value: String(mqttTopology), + options: [ + { + value: "single", + label: "单 Broker", + description: "只配置一个 broker,适合本地或简单环境。", + }, + { + value: "cluster", + label: "集群模式", + description: "配置多个 broker,提高连接发现与故障切换成功率。", + }, + ], + }), + })} + {isKafka && kafkaTopology === "cluster" && renderConfigSectionCard({ @@ -5171,6 +5337,26 @@ const ConnectionModal: React.FC<{ ), })} + {isMQTT && + mqttTopology === "cluster" && + renderConfigSectionCard({ + sectionKey: "replica", + icon: , + children: ( + + + { expect(markup).toContain('>Io'); }); + it('includes MQTT in the selectable database icons', () => { + expect(DB_ICON_TYPES).toContain('mqtt'); + expect(getDbIconLabel('mqtt')).toBe('MQTT'); + const markup = renderToStaticMarkup(<>{getDbIcon('mqtt', undefined, 22)}); + expect(markup).toContain('>Mq'); + }); + it('includes Kafka in the selectable database icons', () => { expect(DB_ICON_TYPES).toContain('kafka'); expect(getDbIconLabel('kafka')).toBe('Kafka'); diff --git a/frontend/src/components/DatabaseIcons.tsx b/frontend/src/components/DatabaseIcons.tsx index 2cb472c..bf55eb3 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', + mqtt: '#0EA5A4', kafka: '#F97316', rabbitmq: '#FF6B35', chroma: '#7C3AED', @@ -194,6 +195,9 @@ const TDengineIcon: React.FC = ({ size = 16, color }) => ( const IoTDBIcon: React.FC = ({ size = 16, color }) => ( ); +const MQTTIcon: React.FC = ({ size = 16, color }) => ( + +); const KafkaIcon: React.FC = ({ size = 16, color }) => ( ); @@ -262,6 +266,7 @@ const DB_ICON_MAP: Record> = { iris: IrisIcon, tdengine: TDengineIcon, iotdb: IoTDBIcon, + mqtt: MQTTIcon, kafka: KafkaIcon, rabbitmq: RabbitMQIcon, chroma: ChromaIcon, @@ -274,7 +279,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', 'kafka', 'rabbitmq', 'chroma', 'qdrant', 'elasticsearch', 'custom', + 'kingbase', 'dameng', 'vastbase', 'opengauss', 'gaussdb', 'goldendb', 'highgo', 'iris', 'tdengine', 'iotdb', 'mqtt', 'kafka', 'rabbitmq', 'chroma', 'qdrant', 'elasticsearch', 'custom', ]; /** 该类型是否有品牌 SVG 文件 */ @@ -296,7 +301,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', kafka: 'Kafka', rabbitmq: 'RabbitMQ', + vastbase: 'VastBase', opengauss: 'OpenGauss', gaussdb: 'GaussDB', goldendb: 'GoldenDB', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', iotdb: 'Apache IoTDB', 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 a5e2504..e9b379f 100644 --- a/frontend/src/components/MessagePublishModal.tsx +++ b/frontend/src/components/MessagePublishModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Form, Input, Modal, Select, Space, Typography, message } from 'antd'; +import { Alert, Checkbox, Form, Input, Modal, Select, Space, Typography, message } from 'antd'; import { DBQuery } from '../../wailsjs/go/app/App'; import type { SavedConnection } from '../types'; @@ -149,6 +149,28 @@ const MessagePublishModal: React.FC = ({ )} + {presentation.showQos && ( + +