mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-10 23:11:40 +08:00
✨ feat(mqtt): 新增 MQTT 数据源连接与测试发消息支持
This commit is contained in:
@@ -33,10 +33,10 @@ describe('ConnectionModal data source registry', () => {
|
|||||||
expect(source).toContain('type === "elasticsearch"');
|
expect(source).toContain('type === "elasticsearch"');
|
||||||
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
|
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
|
||||||
expect(source).toContain(
|
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(
|
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="显示数据库 (留空显示全部)"');
|
expect(source).toContain('label="显示数据库 (留空显示全部)"');
|
||||||
});
|
});
|
||||||
@@ -77,6 +77,19 @@ describe('ConnectionModal data source registry', () => {
|
|||||||
expect(source).toContain('return "fetchSize=1024&timeZone=Asia%2FShanghai";');
|
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', () => {
|
it('exposes Kafka in the create-connection picker with broker and topic defaults', () => {
|
||||||
expect(source).toContain("case 'kafka':");
|
expect(source).toContain("case 'kafka':");
|
||||||
expect(source).toContain('return 9092;');
|
expect(source).toContain('return 9092;');
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ const ConnectionModal: React.FC<{
|
|||||||
);
|
);
|
||||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||||
const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single";
|
const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single";
|
||||||
|
const mqttTopology = Form.useWatch("mqttTopology", form) || "single";
|
||||||
const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single";
|
const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single";
|
||||||
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
|
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
|
||||||
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
|
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
|
||||||
@@ -419,6 +420,7 @@ const ConnectionModal: React.FC<{
|
|||||||
);
|
);
|
||||||
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
|
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
|
||||||
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
|
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
|
||||||
|
const isMQTT = dbType === "mqtt";
|
||||||
const isKafka = dbType === "kafka";
|
const isKafka = dbType === "kafka";
|
||||||
const isRabbitMQ = dbType === "rabbitmq";
|
const isRabbitMQ = dbType === "rabbitmq";
|
||||||
const supportsConnectionParams = supportsConnectionParamsForType(dbType);
|
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") {
|
if (type === "rabbitmq") {
|
||||||
const defaultPort = getDefaultPortByType(type);
|
const defaultPort = getDefaultPortByType(type);
|
||||||
const parsed = parseSingleHostUri(
|
const parsed = parseSingleHostUri(
|
||||||
@@ -1979,6 +2044,9 @@ const ConnectionModal: React.FC<{
|
|||||||
if (dbType === "iotdb") {
|
if (dbType === "iotdb") {
|
||||||
return "iotdb://root:root@127.0.0.1:6667/root.sg";
|
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") {
|
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";
|
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";
|
return "timezone=Asia%2FShanghai";
|
||||||
case "iotdb":
|
case "iotdb":
|
||||||
return "fetchSize=1024&timeZone=Asia%2FShanghai";
|
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":
|
case "kafka":
|
||||||
return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";
|
return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";
|
||||||
case "rabbitmq":
|
case "rabbitmq":
|
||||||
@@ -2136,6 +2206,36 @@ const ConnectionModal: React.FC<{
|
|||||||
return `kafka://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`;
|
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") {
|
if (type === "rabbitmq") {
|
||||||
const address = toAddress(host, port, defaultPort);
|
const address = toAddress(host, port, defaultPort);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -2529,6 +2629,8 @@ const ConnectionModal: React.FC<{
|
|||||||
configType === "sphinx"
|
configType === "sphinx"
|
||||||
? normalizedHosts.slice(1)
|
? normalizedHosts.slice(1)
|
||||||
: [];
|
: [];
|
||||||
|
const mqttHosts =
|
||||||
|
configType === "mqtt" ? normalizedHosts.slice(1) : [];
|
||||||
const kafkaHosts =
|
const kafkaHosts =
|
||||||
configType === "kafka" ? normalizedHosts.slice(1) : [];
|
configType === "kafka" ? normalizedHosts.slice(1) : [];
|
||||||
const mongoHosts =
|
const mongoHosts =
|
||||||
@@ -2538,6 +2640,9 @@ const ConnectionModal: React.FC<{
|
|||||||
const mysqlIsReplica =
|
const mysqlIsReplica =
|
||||||
String(config.topology || "").toLowerCase() === "replica" ||
|
String(config.topology || "").toLowerCase() === "replica" ||
|
||||||
mysqlReplicaHosts.length > 0;
|
mysqlReplicaHosts.length > 0;
|
||||||
|
const mqttIsCluster =
|
||||||
|
String(config.topology || "").toLowerCase() === "cluster" ||
|
||||||
|
mqttHosts.length > 0;
|
||||||
const kafkaIsCluster =
|
const kafkaIsCluster =
|
||||||
String(config.topology || "").toLowerCase() === "cluster" ||
|
String(config.topology || "").toLowerCase() === "cluster" ||
|
||||||
kafkaHosts.length > 0;
|
kafkaHosts.length > 0;
|
||||||
@@ -2613,6 +2718,8 @@ const ConnectionModal: React.FC<{
|
|||||||
timeout: resolvedJvmTimeout,
|
timeout: resolvedJvmTimeout,
|
||||||
mysqlTopology: mysqlIsReplica ? "replica" : "single",
|
mysqlTopology: mysqlIsReplica ? "replica" : "single",
|
||||||
mysqlReplicaHosts: mysqlReplicaHosts,
|
mysqlReplicaHosts: mysqlReplicaHosts,
|
||||||
|
mqttTopology: mqttIsCluster ? "cluster" : "single",
|
||||||
|
mqttHosts: mqttHosts,
|
||||||
kafkaTopology: kafkaIsCluster ? "cluster" : "single",
|
kafkaTopology: kafkaIsCluster ? "cluster" : "single",
|
||||||
kafkaHosts: kafkaHosts,
|
kafkaHosts: kafkaHosts,
|
||||||
mysqlReplicaUser: config.mysqlReplicaUser || "",
|
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") {
|
if (type === "mongodb") {
|
||||||
mongoSrvEnabled = !!mergedValues.mongoSrv;
|
mongoSrvEnabled = !!mergedValues.mongoSrv;
|
||||||
const extraHosts =
|
const extraHosts =
|
||||||
@@ -3826,6 +3950,7 @@ const ConnectionModal: React.FC<{
|
|||||||
includeDatabases: undefined,
|
includeDatabases: undefined,
|
||||||
includeRedisDatabases: undefined,
|
includeRedisDatabases: undefined,
|
||||||
mysqlTopology: "single",
|
mysqlTopology: "single",
|
||||||
|
mqttTopology: "single",
|
||||||
kafkaTopology: "single",
|
kafkaTopology: "single",
|
||||||
redisTopology: "single",
|
redisTopology: "single",
|
||||||
mongoTopology: "single",
|
mongoTopology: "single",
|
||||||
@@ -3836,6 +3961,7 @@ const ConnectionModal: React.FC<{
|
|||||||
mongoAuthMechanism: "",
|
mongoAuthMechanism: "",
|
||||||
savePassword: true,
|
savePassword: true,
|
||||||
mysqlReplicaHosts: [],
|
mysqlReplicaHosts: [],
|
||||||
|
mqttHosts: [],
|
||||||
kafkaHosts: [],
|
kafkaHosts: [],
|
||||||
redisHosts: [],
|
redisHosts: [],
|
||||||
redisSentinelMaster: "",
|
redisSentinelMaster: "",
|
||||||
@@ -3915,7 +4041,7 @@ const ConnectionModal: React.FC<{
|
|||||||
});
|
});
|
||||||
} else if (type !== "custom") {
|
} else if (type !== "custom") {
|
||||||
const defaultUser =
|
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);
|
const sslCapableType = supportsSSLForType(type);
|
||||||
setUseSSL(false);
|
setUseSSL(false);
|
||||||
setUseHttpTunnel(false);
|
setUseHttpTunnel(false);
|
||||||
@@ -3934,6 +4060,7 @@ const ConnectionModal: React.FC<{
|
|||||||
httpTunnelUser: "",
|
httpTunnelUser: "",
|
||||||
httpTunnelPassword: "",
|
httpTunnelPassword: "",
|
||||||
mysqlTopology: "single",
|
mysqlTopology: "single",
|
||||||
|
mqttTopology: "single",
|
||||||
kafkaTopology: "single",
|
kafkaTopology: "single",
|
||||||
redisTopology: "single",
|
redisTopology: "single",
|
||||||
mongoTopology: "single",
|
mongoTopology: "single",
|
||||||
@@ -3944,6 +4071,7 @@ const ConnectionModal: React.FC<{
|
|||||||
mongoAuthMechanism: "",
|
mongoAuthMechanism: "",
|
||||||
savePassword: true,
|
savePassword: true,
|
||||||
mysqlReplicaHosts: [],
|
mysqlReplicaHosts: [],
|
||||||
|
mqttHosts: [],
|
||||||
kafkaHosts: [],
|
kafkaHosts: [],
|
||||||
redisHosts: [],
|
redisHosts: [],
|
||||||
redisSentinelMaster: "",
|
redisSentinelMaster: "",
|
||||||
@@ -5061,6 +5189,22 @@ const ConnectionModal: React.FC<{
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{dbType === "mqtt" &&
|
||||||
|
renderConfigSectionCard({
|
||||||
|
sectionKey: "service",
|
||||||
|
icon: <DatabaseOutlined />,
|
||||||
|
children: (
|
||||||
|
<Form.Item
|
||||||
|
name="database"
|
||||||
|
label="默认 Topic / Filter(可选)"
|
||||||
|
help="留空时必须在 SQL 中显式指定 Topic;填写后可直接执行 SHOW、CONSUME 或 SELECT 预览。支持使用 /、+、#。"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input {...noAutoCapInputProps} placeholder="例如:devices/+/telemetry" />
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
|
||||||
{dbType === "rabbitmq" &&
|
{dbType === "rabbitmq" &&
|
||||||
renderConfigSectionCard({
|
renderConfigSectionCard({
|
||||||
sectionKey: "service",
|
sectionKey: "service",
|
||||||
@@ -5151,6 +5295,28 @@ const ConnectionModal: React.FC<{
|
|||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{isMQTT &&
|
||||||
|
renderConfigSectionCard({
|
||||||
|
sectionKey: "connectionMode",
|
||||||
|
icon: <ClusterOutlined />,
|
||||||
|
children: renderChoiceCards({
|
||||||
|
fieldName: "mqttTopology",
|
||||||
|
value: String(mqttTopology),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "single",
|
||||||
|
label: "单 Broker",
|
||||||
|
description: "只配置一个 broker,适合本地或简单环境。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cluster",
|
||||||
|
label: "集群模式",
|
||||||
|
description: "配置多个 broker,提高连接发现与故障切换成功率。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
|
||||||
{isKafka &&
|
{isKafka &&
|
||||||
kafkaTopology === "cluster" &&
|
kafkaTopology === "cluster" &&
|
||||||
renderConfigSectionCard({
|
renderConfigSectionCard({
|
||||||
@@ -5171,6 +5337,26 @@ const ConnectionModal: React.FC<{
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{isMQTT &&
|
||||||
|
mqttTopology === "cluster" &&
|
||||||
|
renderConfigSectionCard({
|
||||||
|
sectionKey: "replica",
|
||||||
|
icon: <ClusterOutlined />,
|
||||||
|
children: (
|
||||||
|
<Form.Item
|
||||||
|
name="mqttHosts"
|
||||||
|
label="额外 Broker 地址"
|
||||||
|
help="可输入多个 broker 地址,格式:host:port(回车确认)"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
placeholder="例如:10.10.0.12:1883、10.10.0.13:1883"
|
||||||
|
tokenSeparators={[",", ";", " "]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
|
||||||
{isMySQLLike &&
|
{isMySQLLike &&
|
||||||
mysqlTopology === "replica" &&
|
mysqlTopology === "replica" &&
|
||||||
renderConfigSectionCard({
|
renderConfigSectionCard({
|
||||||
@@ -5294,7 +5480,7 @@ const ConnectionModal: React.FC<{
|
|||||||
}
|
}
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
>
|
>
|
||||||
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined} />
|
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "mqtt" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="password"
|
name="password"
|
||||||
@@ -6216,6 +6402,7 @@ const ConnectionModal: React.FC<{
|
|||||||
connectionParams: "",
|
connectionParams: "",
|
||||||
oceanBaseProtocol: "mysql",
|
oceanBaseProtocol: "mysql",
|
||||||
mysqlTopology: "single",
|
mysqlTopology: "single",
|
||||||
|
mqttTopology: "single",
|
||||||
kafkaTopology: "single",
|
kafkaTopology: "single",
|
||||||
redisTopology: "single",
|
redisTopology: "single",
|
||||||
mongoTopology: "single",
|
mongoTopology: "single",
|
||||||
@@ -6224,6 +6411,7 @@ const ConnectionModal: React.FC<{
|
|||||||
mongoAuthMechanism: "",
|
mongoAuthMechanism: "",
|
||||||
savePassword: true,
|
savePassword: true,
|
||||||
mysqlReplicaHosts: [],
|
mysqlReplicaHosts: [],
|
||||||
|
mqttHosts: [],
|
||||||
kafkaHosts: [],
|
kafkaHosts: [],
|
||||||
redisHosts: [],
|
redisHosts: [],
|
||||||
redisSentinelMaster: "",
|
redisSentinelMaster: "",
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ describe('DatabaseIcons', () => {
|
|||||||
expect(markup).toContain('>Io</text>');
|
expect(markup).toContain('>Io</text>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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</text>');
|
||||||
|
});
|
||||||
|
|
||||||
it('includes Kafka in the selectable database icons', () => {
|
it('includes Kafka in the selectable database icons', () => {
|
||||||
expect(DB_ICON_TYPES).toContain('kafka');
|
expect(DB_ICON_TYPES).toContain('kafka');
|
||||||
expect(getDbIconLabel('kafka')).toBe('Kafka');
|
expect(getDbIconLabel('kafka')).toBe('Kafka');
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
|
|||||||
iris: '#1F6FEB',
|
iris: '#1F6FEB',
|
||||||
tdengine: '#2962FF',
|
tdengine: '#2962FF',
|
||||||
iotdb: '#0F766E',
|
iotdb: '#0F766E',
|
||||||
|
mqtt: '#0EA5A4',
|
||||||
kafka: '#F97316',
|
kafka: '#F97316',
|
||||||
rabbitmq: '#FF6B35',
|
rabbitmq: '#FF6B35',
|
||||||
chroma: '#7C3AED',
|
chroma: '#7C3AED',
|
||||||
@@ -194,6 +195,9 @@ const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
|||||||
const IoTDBIcon: 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" />
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.iotdb} label="Io" />
|
||||||
);
|
);
|
||||||
|
const MQTTIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.mqtt} label="Mq" />
|
||||||
|
);
|
||||||
const KafkaIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
const KafkaIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kafka} label="Kf" />
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kafka} label="Kf" />
|
||||||
);
|
);
|
||||||
@@ -262,6 +266,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
|||||||
iris: IrisIcon,
|
iris: IrisIcon,
|
||||||
tdengine: TDengineIcon,
|
tdengine: TDengineIcon,
|
||||||
iotdb: IoTDBIcon,
|
iotdb: IoTDBIcon,
|
||||||
|
mqtt: MQTTIcon,
|
||||||
kafka: KafkaIcon,
|
kafka: KafkaIcon,
|
||||||
rabbitmq: RabbitMQIcon,
|
rabbitmq: RabbitMQIcon,
|
||||||
chroma: ChromaIcon,
|
chroma: ChromaIcon,
|
||||||
@@ -274,7 +279,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
|||||||
export const DB_ICON_TYPES: string[] = [
|
export const DB_ICON_TYPES: string[] = [
|
||||||
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
|
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
|
||||||
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks',
|
'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 文件 */
|
/** 该类型是否有品牌 SVG 文件 */
|
||||||
@@ -296,7 +301,7 @@ export const getDbIconLabel = (type: string): string => {
|
|||||||
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
|
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
|
||||||
starrocks: 'StarRocks',
|
starrocks: 'StarRocks',
|
||||||
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
|
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',
|
chroma: 'Chroma',
|
||||||
qdrant: 'Qdrant',
|
qdrant: 'Qdrant',
|
||||||
elasticsearch: 'Elasticsearch',
|
elasticsearch: 'Elasticsearch',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 { DBQuery } from '../../wailsjs/go/app/App';
|
||||||
import type { SavedConnection } from '../types';
|
import type { SavedConnection } from '../types';
|
||||||
@@ -149,6 +149,28 @@ const MessagePublishModal: React.FC<MessagePublishModalProps> = ({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{presentation.showQos && (
|
||||||
|
<Form.Item
|
||||||
|
label="QoS"
|
||||||
|
name="qos"
|
||||||
|
extra="0 为至多一次,1 为至少一次,2 为仅一次。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: '0 · At most once', value: 0 },
|
||||||
|
{ label: '1 · At least once', value: 1 },
|
||||||
|
{ label: '2 · Exactly once', value: 2 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{presentation.showRetain && (
|
||||||
|
<Form.Item name="retain" valuePropName="checked" style={{ marginBottom: 16 }}>
|
||||||
|
<Checkbox>Retain 消息</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
{presentation.showKey && (
|
{presentation.showKey && (
|
||||||
<Form.Item label="消息 Key(可选)">
|
<Form.Item label="消息 Key(可选)">
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const normalizeDriverType = (value: string): string => {
|
|||||||
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
|
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
|
||||||
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
|
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
|
||||||
if (normalized === 'apache-iotdb' || normalized === 'apache_iotdb') return 'iotdb';
|
if (normalized === 'apache-iotdb' || normalized === 'apache_iotdb') return 'iotdb';
|
||||||
|
if (normalized === 'mqtts') return 'mqtt';
|
||||||
if (normalized === 'apache-kafka' || normalized === 'apache_kafka') return 'kafka';
|
if (normalized === 'apache-kafka' || normalized === 'apache_kafka') return 'kafka';
|
||||||
if (normalized === 'rabbit-mq' || normalized === 'rabbit_mq') return 'rabbitmq';
|
if (normalized === 'rabbit-mq' || normalized === 'rabbit_mq') return 'rabbitmq';
|
||||||
if (normalized === 'doris') return 'diros';
|
if (normalized === 'doris') return 'diros';
|
||||||
|
|||||||
@@ -283,6 +283,20 @@ export const resolveConnectionConfigLayout = (
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (type === 'mqtt') {
|
||||||
|
return {
|
||||||
|
kind: 'generic-sql',
|
||||||
|
sections: [
|
||||||
|
'identity',
|
||||||
|
'uri',
|
||||||
|
'target',
|
||||||
|
'connectionMode',
|
||||||
|
'replica',
|
||||||
|
'service',
|
||||||
|
'credentials',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (type === 'kafka') {
|
if (type === 'kafka') {
|
||||||
return {
|
return {
|
||||||
kind: 'generic-sql',
|
kind: 'generic-sql',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
|
|||||||
elasticsearch: ["http", "https"],
|
elasticsearch: ["http", "https"],
|
||||||
chroma: ["http", "https", "chroma"],
|
chroma: ["http", "https", "chroma"],
|
||||||
qdrant: ["http", "https", "qdrant"],
|
qdrant: ["http", "https", "qdrant"],
|
||||||
|
mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"],
|
||||||
rabbitmq: ["rabbitmq", "http", "https"],
|
rabbitmq: ["rabbitmq", "http", "https"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ const normalizeConnectionType = (type: string) =>
|
|||||||
case "greatdb":
|
case "greatdb":
|
||||||
case "gdb":
|
case "gdb":
|
||||||
return "goldendb";
|
return "goldendb";
|
||||||
|
case "mqtts":
|
||||||
|
return "mqtt";
|
||||||
default:
|
default:
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ const sslSupportedTypes = new Set([
|
|||||||
"elasticsearch",
|
"elasticsearch",
|
||||||
"chroma",
|
"chroma",
|
||||||
"qdrant",
|
"qdrant",
|
||||||
|
"mqtt",
|
||||||
"kafka",
|
"kafka",
|
||||||
"rabbitmq",
|
"rabbitmq",
|
||||||
]);
|
]);
|
||||||
@@ -87,6 +91,7 @@ const sslCAPathSupportedTypes = new Set([
|
|||||||
"elasticsearch",
|
"elasticsearch",
|
||||||
"chroma",
|
"chroma",
|
||||||
"qdrant",
|
"qdrant",
|
||||||
|
"mqtt",
|
||||||
"kafka",
|
"kafka",
|
||||||
"rabbitmq",
|
"rabbitmq",
|
||||||
]);
|
]);
|
||||||
@@ -109,6 +114,7 @@ const sslClientCertificateSupportedTypes = new Set([
|
|||||||
"gaussdb",
|
"gaussdb",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"redis",
|
"redis",
|
||||||
|
"mqtt",
|
||||||
"kafka",
|
"kafka",
|
||||||
"rabbitmq",
|
"rabbitmq",
|
||||||
]);
|
]);
|
||||||
@@ -161,5 +167,6 @@ export const supportsConnectionParamsForType = (type: string) =>
|
|||||||
type === "elasticsearch" ||
|
type === "elasticsearch" ||
|
||||||
type === "chroma" ||
|
type === "chroma" ||
|
||||||
type === "qdrant" ||
|
type === "qdrant" ||
|
||||||
|
type === "mqtt" ||
|
||||||
type === "kafka" ||
|
type === "kafka" ||
|
||||||
type === "rabbitmq";
|
type === "rabbitmq";
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '消息队列',
|
label: '消息队列',
|
||||||
items: [
|
items: [
|
||||||
|
{ key: 'mqtt', name: 'MQTT' },
|
||||||
{ key: 'kafka', name: 'Kafka' },
|
{ key: 'kafka', name: 'Kafka' },
|
||||||
{ key: 'rabbitmq', name: 'RabbitMQ' },
|
{ key: 'rabbitmq', name: 'RabbitMQ' },
|
||||||
],
|
],
|
||||||
@@ -123,6 +124,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
|
|||||||
return 8000;
|
return 8000;
|
||||||
case 'qdrant':
|
case 'qdrant':
|
||||||
return 6333;
|
return 6333;
|
||||||
|
case 'mqtt':
|
||||||
|
return 1883;
|
||||||
case 'kafka':
|
case 'kafka':
|
||||||
return 9092;
|
return 9092;
|
||||||
case 'rabbitmq':
|
case 'rabbitmq':
|
||||||
@@ -159,6 +162,8 @@ export const getConnectionTypeHint = (type: string): string => {
|
|||||||
return 'Collection 浏览、向量搜索和 Payload 过滤';
|
return 'Collection 浏览、向量搜索和 Payload 过滤';
|
||||||
case 'iotdb':
|
case 'iotdb':
|
||||||
return 'Storage Group / Device / Timeseries';
|
return 'Storage Group / Device / Timeseries';
|
||||||
|
case 'mqtt':
|
||||||
|
return 'Broker / Topic Filter / QoS';
|
||||||
case 'kafka':
|
case 'kafka':
|
||||||
return 'Broker / Topic / Consumer Group';
|
return 'Broker / Topic / Consumer Group';
|
||||||
case 'rabbitmq':
|
case 'rabbitmq':
|
||||||
|
|||||||
@@ -164,6 +164,27 @@ describe('dataSourceCapabilities', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats MQTT as a queryable messaging datasource with manual total count and publish support', () => {
|
||||||
|
expect(getDataSourceCapabilities({ type: 'mqtt' })).toMatchObject({
|
||||||
|
type: 'mqtt',
|
||||||
|
supportsQueryEditor: true,
|
||||||
|
supportsSqlQueryExport: false,
|
||||||
|
supportsCopyInsert: false,
|
||||||
|
supportsCreateDatabase: false,
|
||||||
|
supportsRenameDatabase: false,
|
||||||
|
supportsDropDatabase: false,
|
||||||
|
supportsMessagePublish: true,
|
||||||
|
forceReadOnlyQueryResult: true,
|
||||||
|
preferManualTotalCount: true,
|
||||||
|
});
|
||||||
|
expect(getDataSourceCapabilities({ type: 'custom', driver: 'mqtts' })).toMatchObject({
|
||||||
|
type: 'mqtt',
|
||||||
|
supportsQueryEditor: true,
|
||||||
|
supportsMessagePublish: true,
|
||||||
|
preferManualTotalCount: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('treats Kafka as a queryable read-only messaging datasource', () => {
|
it('treats Kafka as a queryable read-only messaging datasource', () => {
|
||||||
expect(getDataSourceCapabilities({ type: 'kafka' })).toMatchObject({
|
expect(getDataSourceCapabilities({ type: 'kafka' })).toMatchObject({
|
||||||
type: 'kafka',
|
type: 'kafka',
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const normalizeDataSourceToken = (raw: string): string => {
|
|||||||
case 'qdrantdb':
|
case 'qdrantdb':
|
||||||
case 'qdrant-db':
|
case 'qdrant-db':
|
||||||
return 'qdrant';
|
return 'qdrant';
|
||||||
|
case 'mqtt':
|
||||||
|
case 'mqtts':
|
||||||
|
return 'mqtt';
|
||||||
case 'apache-iotdb':
|
case 'apache-iotdb':
|
||||||
case 'apache_iotdb':
|
case 'apache_iotdb':
|
||||||
return 'iotdb';
|
return 'iotdb';
|
||||||
@@ -121,9 +124,9 @@ const COPY_INSERT_TYPES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'kafka', 'rabbitmq']);
|
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'mqtt', 'kafka', 'rabbitmq']);
|
||||||
const MESSAGE_PUBLISH_TYPES = new Set(['kafka', 'rabbitmq']);
|
const MESSAGE_PUBLISH_TYPES = new Set(['mqtt', 'kafka', 'rabbitmq']);
|
||||||
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'mqtt']);
|
||||||
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||||
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
|
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,37 @@ describe('messagePublish', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds an MQTT publish JSON command with qos and retain flags', () => {
|
||||||
|
const result = buildMessagePublishCommand(
|
||||||
|
{ type: 'mqtt' },
|
||||||
|
{
|
||||||
|
destination: 'devices/device-001/telemetry',
|
||||||
|
qos: 1,
|
||||||
|
retain: true,
|
||||||
|
bodyMode: 'json',
|
||||||
|
body: '{"id":1,"event":"created"}',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.transportLabel).toBe('MQTT Topic');
|
||||||
|
expect(result.destinationLabel).toBe('devices/device-001/telemetry');
|
||||||
|
expect(result.commandText).toContain('"publish": "devices/device-001/telemetry"');
|
||||||
|
expect(result.commandText).toContain('"qos": 1');
|
||||||
|
expect(result.commandText).toContain('"retain": true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds MQTT default publish draft with connection qos and retain defaults', () => {
|
||||||
|
expect(createDefaultMessagePublishDraft(
|
||||||
|
{ type: 'mqtt', database: 'devices/+/telemetry', connectionParams: 'qos=1&retain=true' },
|
||||||
|
'',
|
||||||
|
)).toMatchObject({
|
||||||
|
destination: 'devices/+/telemetry',
|
||||||
|
qos: 1,
|
||||||
|
retain: true,
|
||||||
|
bodyMode: 'json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('builds a RabbitMQ publish JSON command with routing and properties', () => {
|
it('builds a RabbitMQ publish JSON command with routing and properties', () => {
|
||||||
const result = buildMessagePublishCommand(
|
const result = buildMessagePublishCommand(
|
||||||
{ type: 'rabbitmq', connectionParams: 'defaultQueue=orders.queue&exchange=events.topic' },
|
{ type: 'rabbitmq', connectionParams: 'defaultQueue=orders.queue&exchange=events.topic' },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type MessagePublishDraft = {
|
|||||||
destination: string;
|
destination: string;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
routingKey?: string;
|
routingKey?: string;
|
||||||
|
qos?: number;
|
||||||
|
retain?: boolean;
|
||||||
keyMode?: MessagePublishValueMode;
|
keyMode?: MessagePublishValueMode;
|
||||||
key?: string;
|
key?: string;
|
||||||
bodyMode?: MessagePublishValueMode;
|
bodyMode?: MessagePublishValueMode;
|
||||||
@@ -40,6 +42,8 @@ export type MessagePublishPresentation = {
|
|||||||
showExchange: boolean;
|
showExchange: boolean;
|
||||||
showRoutingKey: boolean;
|
showRoutingKey: boolean;
|
||||||
showProperties: boolean;
|
showProperties: boolean;
|
||||||
|
showQos: boolean;
|
||||||
|
showRetain: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeMode = (value: unknown, fallback: MessagePublishValueMode): MessagePublishValueMode => {
|
const normalizeMode = (value: unknown, fallback: MessagePublishValueMode): MessagePublishValueMode => {
|
||||||
@@ -138,6 +142,9 @@ const resolveDefaultDestination = (config: ConnectionLike, explicitDestination:
|
|||||||
if (resolvedType === 'kafka') {
|
if (resolvedType === 'kafka') {
|
||||||
return String(config?.database || '').trim();
|
return String(config?.database || '').trim();
|
||||||
}
|
}
|
||||||
|
if (resolvedType === 'mqtt') {
|
||||||
|
return String(config?.database || params.get('defaultTopic') || params.get('topic') || '').trim();
|
||||||
|
}
|
||||||
if (resolvedType === 'rabbitmq') {
|
if (resolvedType === 'rabbitmq') {
|
||||||
return String(params.get('defaultQueue') || params.get('queue') || '').trim();
|
return String(params.get('defaultQueue') || params.get('queue') || '').trim();
|
||||||
}
|
}
|
||||||
@@ -161,6 +168,25 @@ export const getMessagePublishPresentation = (
|
|||||||
showExchange: true,
|
showExchange: true,
|
||||||
showRoutingKey: true,
|
showRoutingKey: true,
|
||||||
showProperties: true,
|
showProperties: true,
|
||||||
|
showQos: false,
|
||||||
|
showRetain: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedType === 'mqtt') {
|
||||||
|
return {
|
||||||
|
transportLabel: 'MQTT Topic',
|
||||||
|
destinationLabel: 'Topic',
|
||||||
|
destinationPlaceholder: '例如:devices/device-001/telemetry',
|
||||||
|
destinationRequiredMessage: '请输入 Topic',
|
||||||
|
alertMessage: '当前表单会自动拼装 MQTT publish JSON 命令,并直接通过 broker 执行测试发送。',
|
||||||
|
successHint: 'QoS 与 retain 可单独指定;未填写时沿用当前连接中的默认参数。',
|
||||||
|
showKey: false,
|
||||||
|
showExchange: false,
|
||||||
|
showRoutingKey: false,
|
||||||
|
showProperties: false,
|
||||||
|
showQos: true,
|
||||||
|
showRetain: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +201,8 @@ export const getMessagePublishPresentation = (
|
|||||||
showExchange: false,
|
showExchange: false,
|
||||||
showRoutingKey: false,
|
showRoutingKey: false,
|
||||||
showProperties: false,
|
showProperties: false,
|
||||||
|
showQos: false,
|
||||||
|
showRetain: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,6 +226,18 @@ export const createDefaultMessagePublishDraft = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolvedType === 'mqtt') {
|
||||||
|
const qosValue = Number(params.get('qos'));
|
||||||
|
return {
|
||||||
|
destination: resolvedDestination,
|
||||||
|
qos: Number.isFinite(qosValue) ? Math.min(2, Math.max(0, Math.trunc(qosValue))) : 0,
|
||||||
|
retain: ['1', 'true', 'yes', 'on'].includes(String(params.get('retain') || '').trim().toLowerCase()),
|
||||||
|
bodyMode: 'json',
|
||||||
|
body: '{\n "event": "test",\n "source": "gonavi"\n}',
|
||||||
|
headers: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destination: resolvedDestination,
|
destination: resolvedDestination,
|
||||||
keyMode: 'text',
|
keyMode: 'text',
|
||||||
@@ -218,6 +258,27 @@ export const buildMessagePublishCommand = (
|
|||||||
throw new Error('请输入目标 Topic / Queue');
|
throw new Error('请输入目标 Topic / Queue');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolvedType === 'mqtt') {
|
||||||
|
if (/[#+]/.test(destination)) {
|
||||||
|
throw new Error('MQTT 发送 Topic 不能包含 + 或 # 通配符');
|
||||||
|
}
|
||||||
|
const bodyMode = normalizeMode(draft.bodyMode, 'json');
|
||||||
|
const qosValue = Number(draft.qos);
|
||||||
|
const qos = Number.isFinite(qosValue) ? Math.min(2, Math.max(0, Math.trunc(qosValue))) : 0;
|
||||||
|
const command: Record<string, unknown> = {
|
||||||
|
publish: destination,
|
||||||
|
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
|
||||||
|
qos,
|
||||||
|
retain: !!draft.retain,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandText: JSON.stringify(command, null, 2),
|
||||||
|
destinationLabel: destination,
|
||||||
|
transportLabel: 'MQTT Topic',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (resolvedType === 'rabbitmq') {
|
if (resolvedType === 'rabbitmq') {
|
||||||
const params = resolveConnectionParams(config);
|
const params = resolveConnectionParams(config);
|
||||||
const bodyMode = normalizeMode(draft.bodyMode, 'json');
|
const bodyMode = normalizeMode(draft.bodyMode, 'json');
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ describe('buildTableSelectQuery', () => {
|
|||||||
expect(buildTableSelectQuery('kafka', 'logs.app-1')).toBe('SELECT * FROM "logs.app-1" LIMIT 100;');
|
expect(buildTableSelectQuery('kafka', 'logs.app-1')).toBe('SELECT * FROM "logs.app-1" LIMIT 100;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds a preview limit for MQTT topic browsing', () => {
|
||||||
|
expect(buildTableSelectQuery('mqtt', 'devices/+/telemetry')).toBe('SELECT * FROM "devices/+/telemetry" LIMIT 100;');
|
||||||
|
});
|
||||||
|
|
||||||
it('adds a preview limit for RabbitMQ queue browsing', () => {
|
it('adds a preview limit for RabbitMQ queue browsing', () => {
|
||||||
expect(buildTableSelectQuery('rabbitmq', 'orders.events.v1')).toBe('SELECT * FROM "orders.events.v1" LIMIT 100;');
|
expect(buildTableSelectQuery('rabbitmq', 'orders.events.v1')).toBe('SELECT * FROM "orders.events.v1" LIMIT 100;');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const buildTableSelectQuery = (dbType: string, tableName: string): string
|
|||||||
if (!normalizedTableName) {
|
if (!normalizedTableName) {
|
||||||
return 'SELECT * FROM ';
|
return 'SELECT * FROM ';
|
||||||
}
|
}
|
||||||
if (['kafka', 'rabbitmq'].includes(String(dbType || '').trim().toLowerCase())) {
|
if (['mqtt', 'kafka', 'rabbitmq'].includes(String(dbType || '').trim().toLowerCase())) {
|
||||||
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)} LIMIT 100;`;
|
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)} LIMIT 100;`;
|
||||||
}
|
}
|
||||||
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
|
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ describe('quoteQualifiedIdent', () => {
|
|||||||
.toBe('`root`.`sg`.`d1`');
|
.toBe('`root`.`sg`.`d1`');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps MQTT topic filters as one quoted identifier', () => {
|
||||||
|
expect(quoteQualifiedIdent('mqtt', 'devices/+/telemetry.v1'))
|
||||||
|
.toBe('"devices/+/telemetry.v1"');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps Kafka topic names as one quoted identifier', () => {
|
it('keeps Kafka topic names as one quoted identifier', () => {
|
||||||
expect(quoteQualifiedIdent('kafka', 'logs.app-1'))
|
expect(quoteQualifiedIdent('kafka', 'logs.app-1'))
|
||||||
.toBe('"logs.app-1"');
|
.toBe('"logs.app-1"');
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
|||||||
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
||||||
const raw = (ident || '').trim();
|
const raw = (ident || '').trim();
|
||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
if (['kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
|
if (['mqtt', 'kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
|
||||||
return quoteIdentPart(dbType, raw);
|
return quoteIdentPart(dbType, raw);
|
||||||
}
|
}
|
||||||
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
|
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ describe('sqlDialect', () => {
|
|||||||
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
|
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
|
||||||
expect(resolveSqlDialect('Apache-IoTDB')).toBe('iotdb');
|
expect(resolveSqlDialect('Apache-IoTDB')).toBe('iotdb');
|
||||||
expect(resolveSqlDialect('custom', 'apache_iotdb')).toBe('iotdb');
|
expect(resolveSqlDialect('custom', 'apache_iotdb')).toBe('iotdb');
|
||||||
|
expect(resolveSqlDialect('MQTTS')).toBe('mqtt');
|
||||||
|
expect(resolveSqlDialect('custom', 'mqtts')).toBe('mqtt');
|
||||||
expect(resolveSqlDialect('Apache-Kafka')).toBe('kafka');
|
expect(resolveSqlDialect('Apache-Kafka')).toBe('kafka');
|
||||||
expect(resolveSqlDialect('custom', 'apache_kafka')).toBe('kafka');
|
expect(resolveSqlDialect('custom', 'apache_kafka')).toBe('kafka');
|
||||||
expect(resolveSqlDialect('Rabbit-MQ')).toBe('rabbitmq');
|
expect(resolveSqlDialect('Rabbit-MQ')).toBe('rabbitmq');
|
||||||
@@ -75,6 +77,11 @@ describe('sqlDialect', () => {
|
|||||||
expect(resolveSqlKeywords('iotdb')).not.toEqual(expect.arrayContaining(['TAGS', 'USING']));
|
expect(resolveSqlKeywords('iotdb')).not.toEqual(expect.arrayContaining(['TAGS', 'USING']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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']));
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves Kafka completion keywords for topic discovery and consume syntax', () => {
|
it('resolves Kafka completion keywords for topic discovery and consume syntax', () => {
|
||||||
expect(resolveSqlKeywords('kafka')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
|
expect(resolveSqlKeywords('kafka')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
|
||||||
expect(resolveSqlKeywords('kafka')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
|
expect(resolveSqlKeywords('kafka')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type SqlDialect =
|
|||||||
| 'clickhouse'
|
| 'clickhouse'
|
||||||
| 'tdengine'
|
| 'tdengine'
|
||||||
| 'iotdb'
|
| 'iotdb'
|
||||||
|
| 'mqtt'
|
||||||
| 'kafka'
|
| 'kafka'
|
||||||
| 'rabbitmq'
|
| 'rabbitmq'
|
||||||
| 'mongodb'
|
| 'mongodb'
|
||||||
@@ -137,6 +138,9 @@ export const resolveSqlDialect = (
|
|||||||
case 'apache-iotdb':
|
case 'apache-iotdb':
|
||||||
case 'apache_iotdb':
|
case 'apache_iotdb':
|
||||||
return 'iotdb';
|
return 'iotdb';
|
||||||
|
case 'mqtt':
|
||||||
|
case 'mqtts':
|
||||||
|
return 'mqtt';
|
||||||
case 'kafka':
|
case 'kafka':
|
||||||
case 'apache-kafka':
|
case 'apache-kafka':
|
||||||
case 'apache_kafka':
|
case 'apache_kafka':
|
||||||
@@ -169,6 +173,7 @@ export const resolveSqlDialect = (
|
|||||||
if (source.includes('clickhouse')) return 'clickhouse';
|
if (source.includes('clickhouse')) return 'clickhouse';
|
||||||
if (source.includes('tdengine')) return 'tdengine';
|
if (source.includes('tdengine')) return 'tdengine';
|
||||||
if (source.includes('iotdb')) return 'iotdb';
|
if (source.includes('iotdb')) return 'iotdb';
|
||||||
|
if (source.includes('mqtt')) return 'mqtt';
|
||||||
if (source.includes('kafka')) return 'kafka';
|
if (source.includes('kafka')) return 'kafka';
|
||||||
if (source.includes('rabbitmq') || source.includes('rabbit-mq') || source.includes('rabbit_mq')) return 'rabbitmq';
|
if (source.includes('rabbitmq') || source.includes('rabbit-mq') || source.includes('rabbit_mq')) return 'rabbitmq';
|
||||||
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
|
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
|
||||||
@@ -623,6 +628,15 @@ const IOTDB_KEYWORDS = [
|
|||||||
'COMPRESSION',
|
'COMPRESSION',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MQTT_KEYWORDS = [
|
||||||
|
'SHOW TOPICS',
|
||||||
|
'DESCRIBE TOPIC',
|
||||||
|
'CONSUME',
|
||||||
|
'FROM',
|
||||||
|
'LIMIT',
|
||||||
|
'OFFSET',
|
||||||
|
];
|
||||||
|
|
||||||
const KAFKA_KEYWORDS = [
|
const KAFKA_KEYWORDS = [
|
||||||
'SHOW TOPICS',
|
'SHOW TOPICS',
|
||||||
'SHOW TOPIC',
|
'SHOW TOPIC',
|
||||||
@@ -658,6 +672,7 @@ export const resolveSqlKeywords = (dbType: string): string[] => {
|
|||||||
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
|
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
|
||||||
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
|
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
|
||||||
if (dialect === 'iotdb') return unique([...COMMON_KEYWORDS, ...IOTDB_KEYWORDS]);
|
if (dialect === 'iotdb') return unique([...COMMON_KEYWORDS, ...IOTDB_KEYWORDS]);
|
||||||
|
if (dialect === 'mqtt') return unique([...COMMON_KEYWORDS, ...MQTT_KEYWORDS]);
|
||||||
if (dialect === 'kafka') return unique([...COMMON_KEYWORDS, ...KAFKA_KEYWORDS]);
|
if (dialect === 'kafka') return unique([...COMMON_KEYWORDS, ...KAFKA_KEYWORDS]);
|
||||||
if (dialect === 'rabbitmq') return unique([...COMMON_KEYWORDS, ...RABBITMQ_KEYWORDS]);
|
if (dialect === 'rabbitmq') return unique([...COMMON_KEYWORDS, ...RABBITMQ_KEYWORDS]);
|
||||||
return COMMON_KEYWORDS;
|
return COMMON_KEYWORDS;
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -6,10 +6,12 @@ require (
|
|||||||
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
||||||
gitee.com/chunanyong/dm v1.8.22
|
gitee.com/chunanyong/dm v1.8.22
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
|
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
|
||||||
|
github.com/HuaweiCloudDeveloper/gaussdb-go v1.0.0-rc1
|
||||||
github.com/apache/iotdb-client-go v1.3.7
|
github.com/apache/iotdb-client-go v1.3.7
|
||||||
github.com/caretdev/go-irisnative v0.2.1
|
github.com/caretdev/go-irisnative v0.2.1
|
||||||
github.com/duckdb/duckdb-go/v2 v2.5.5
|
github.com/duckdb/duckdb-go/v2 v2.5.5
|
||||||
github.com/elastic/go-elasticsearch/v8 v8.19.6
|
github.com/elastic/go-elasticsearch/v8 v8.19.6
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/highgo/pq-sm3 v0.0.0
|
github.com/highgo/pq-sm3 v0.0.0
|
||||||
@@ -17,6 +19,7 @@ require (
|
|||||||
github.com/microsoft/go-mssqldb v1.9.6
|
github.com/microsoft/go-mssqldb v1.9.6
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||||
github.com/redis/go-redis/v9 v9.17.3
|
github.com/redis/go-redis/v9 v9.17.3
|
||||||
|
github.com/segmentio/kafka-go v0.4.51
|
||||||
github.com/sijms/go-ora/v2 v2.9.0
|
github.com/sijms/go-ora/v2 v2.9.0
|
||||||
github.com/taosdata/driver-go/v3 v3.7.8
|
github.com/taosdata/driver-go/v3 v3.7.8
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
@@ -32,7 +35,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/HuaweiCloudDeveloper/gaussdb-go v1.0.0-rc1 // indirect
|
|
||||||
github.com/apache/thrift v0.22.0 // indirect
|
github.com/apache/thrift v0.22.0 // indirect
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect
|
github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
@@ -42,7 +44,6 @@ require (
|
|||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/segmentio/kafka-go v0.4.51 // indirect
|
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -74,6 +74,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
||||||
github.com/elastic/go-elasticsearch/v8 v8.19.6 h1:4qa7ecJkr5rLsoHKIVGbaqcFt2o57CnOHQJi9Pts/rk=
|
github.com/elastic/go-elasticsearch/v8 v8.19.6 h1:4qa7ecJkr5rLsoHKIVGbaqcFt2o57CnOHQJi9Pts/rk=
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||||
|
case "mqtt", "mqtts":
|
||||||
|
// MQTT 的 Database 字段表示默认 Topic,不能被树上的 synthetic database(topics) 覆盖。
|
||||||
case "kafka", "apache-kafka", "apache_kafka":
|
case "kafka", "apache-kafka", "apache_kafka":
|
||||||
// Kafka 的 Database 字段表示默认 Topic,不能被树上的 synthetic database(topics) 覆盖。
|
// Kafka 的 Database 字段表示默认 Topic,不能被树上的 synthetic database(topics) 覆盖。
|
||||||
case "oceanbase":
|
case "oceanbase":
|
||||||
@@ -53,7 +55,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
|||||||
|
|
||||||
// Elasticsearch:索引名可能含多个点(如 iot_pro_biz_operate_log.index.20240626),
|
// Elasticsearch:索引名可能含多个点(如 iot_pro_biz_operate_log.index.20240626),
|
||||||
// 不能按点分割,直接返回原始数据库名和完整表名。
|
// 不能按点分割,直接返回原始数据库名和完整表名。
|
||||||
if dbType == "elasticsearch" || dbType == "iotdb" || dbType == "kafka" || dbType == "rabbitmq" {
|
if dbType == "elasticsearch" || dbType == "iotdb" || dbType == "mqtt" || dbType == "kafka" || dbType == "rabbitmq" {
|
||||||
return rawDB, rawTable
|
return rawDB, rawTable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
|||||||
func normalizeMetadataSchemaAndTable(config connection.ConnectionConfig, dbName string, tableName string) (string, string) {
|
func normalizeMetadataSchemaAndTable(config connection.ConnectionConfig, dbName string, tableName string) (string, string) {
|
||||||
schema, table := normalizeSchemaAndTable(config, dbName, tableName)
|
schema, table := normalizeSchemaAndTable(config, dbName, tableName)
|
||||||
switch resolveDDLDBType(config) {
|
switch resolveDDLDBType(config) {
|
||||||
case "kafka", "rabbitmq":
|
case "mqtt", "kafka", "rabbitmq":
|
||||||
return schema, table
|
return schema, table
|
||||||
case "postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb":
|
case "postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb":
|
||||||
rawTable := strings.TrimSpace(tableName)
|
rawTable := strings.TrimSpace(tableName)
|
||||||
|
|||||||
@@ -332,6 +332,18 @@ func TestNormalizeSchemaAndTable_KafkaPreservesDottedTopicName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSchemaAndTable_MQTTPreservesTopicFilter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
schemaOrDb, table := normalizeSchemaAndTable(connection.ConnectionConfig{
|
||||||
|
Type: "mqtt",
|
||||||
|
}, "topics", "devices/floor1.sensor.v1")
|
||||||
|
|
||||||
|
if schemaOrDb != "topics" || table != "devices/floor1.sensor.v1" {
|
||||||
|
t.Fatalf("expected mqtt topic filter to stay intact, got %q.%q", schemaOrDb, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeSchemaAndTable_RabbitMQPreservesDottedQueueName(t *testing.T) {
|
func TestNormalizeSchemaAndTable_RabbitMQPreservesDottedQueueName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -356,6 +368,18 @@ func TestNormalizeMetadataSchemaAndTable_KafkaPreservesDottedTopicName(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMetadataSchemaAndTable_MQTTPreservesTopicFilter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
schemaOrDb, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{
|
||||||
|
Type: "mqtt",
|
||||||
|
}, "topics", "devices/floor1.sensor.v1")
|
||||||
|
|
||||||
|
if schemaOrDb != "topics" || table != "devices/floor1.sensor.v1" {
|
||||||
|
t.Fatalf("expected mqtt metadata topic filter to stay intact, got %q.%q", schemaOrDb, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeMetadataSchemaAndTable_RabbitMQPreservesDottedQueueName(t *testing.T) {
|
func TestNormalizeMetadataSchemaAndTable_RabbitMQPreservesDottedQueueName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ func normalizeSchemaAndTableByType(dbType string, dbName string, tableName strin
|
|||||||
return rawDB, rawTable
|
return rawDB, rawTable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elasticsearch / RabbitMQ / Kafka:对象名可能含多个点,不能按点分割
|
// Elasticsearch / MQTT / RabbitMQ / Kafka:对象名可能含多个点或路径,不能按点分割
|
||||||
if dbType == "elasticsearch" || dbType == "kafka" || dbType == "rabbitmq" {
|
if dbType == "elasticsearch" || dbType == "mqtt" || dbType == "kafka" || dbType == "rabbitmq" {
|
||||||
return rawDB, rawTable
|
return rawDB, rawTable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ func TestNormalizeSchemaAndTableByType_KafkaPreservesDottedTopicName(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSchemaAndTableByType_MQTTPreservesTopicFilter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
schema, table := normalizeSchemaAndTableByType("mqtt", "topics", "devices/floor1.sensor.v1")
|
||||||
|
if schema != "topics" || table != "devices/floor1.sensor.v1" {
|
||||||
|
t.Fatalf("expected mqtt topic filter to stay intact, got %q.%q", schema, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeSchemaAndTableByType_RabbitMQPreservesDottedQueueName(t *testing.T) {
|
func TestNormalizeSchemaAndTableByType_RabbitMQPreservesDottedQueueName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1432,6 +1432,8 @@ func normalizeDriverType(driverType string) string {
|
|||||||
return "gaussdb"
|
return "gaussdb"
|
||||||
case "goldendb", "greatdb", "gdb":
|
case "goldendb", "greatdb", "gdb":
|
||||||
return "goldendb"
|
return "goldendb"
|
||||||
|
case "mqtt", "mqtts":
|
||||||
|
return "mqtt"
|
||||||
case "kafka", "apache-kafka", "apache_kafka":
|
case "kafka", "apache-kafka", "apache_kafka":
|
||||||
return "kafka"
|
return "kafka"
|
||||||
case "rabbitmq", "rabbit-mq", "rabbit_mq":
|
case "rabbitmq", "rabbit-mq", "rabbit_mq":
|
||||||
@@ -1505,6 +1507,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [
|
|||||||
{Type: "oracle", Name: "Oracle", Engine: driverEngineGo, BuiltIn: true},
|
{Type: "oracle", Name: "Oracle", Engine: driverEngineGo, BuiltIn: true},
|
||||||
{Type: "redis", Name: "Redis", Engine: driverEngineGo, BuiltIn: true},
|
{Type: "redis", Name: "Redis", Engine: driverEngineGo, BuiltIn: true},
|
||||||
{Type: "postgres", Name: "PostgreSQL", Engine: driverEngineGo, BuiltIn: true},
|
{Type: "postgres", Name: "PostgreSQL", Engine: driverEngineGo, BuiltIn: true},
|
||||||
|
{Type: "mqtt", Name: "MQTT", Engine: driverEngineGo, BuiltIn: true},
|
||||||
{Type: "kafka", Name: "Kafka", Engine: driverEngineGo, BuiltIn: true},
|
{Type: "kafka", Name: "Kafka", Engine: driverEngineGo, BuiltIn: true},
|
||||||
{Type: "rabbitmq", Name: "RabbitMQ", Engine: driverEngineGo, BuiltIn: true},
|
{Type: "rabbitmq", Name: "RabbitMQ", Engine: driverEngineGo, BuiltIn: true},
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,22 @@ func TestKafkaDriverDefinitionIsBuiltIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMQTTDriverDefinitionIsBuiltIn(t *testing.T) {
|
||||||
|
definition, ok := resolveDriverDefinition("mqtts")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected mqtt driver definition")
|
||||||
|
}
|
||||||
|
if definition.Name != "MQTT" {
|
||||||
|
t.Fatalf("unexpected mqtt driver name: %q", definition.Name)
|
||||||
|
}
|
||||||
|
if !definition.BuiltIn {
|
||||||
|
t.Fatal("expected mqtt to be a built-in driver")
|
||||||
|
}
|
||||||
|
if definition.PinnedVersion != "" || definition.DefaultDownloadURL != "" {
|
||||||
|
t.Fatalf("expected mqtt builtin definition to omit optional-agent metadata: %#v", definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRabbitMQDriverDefinitionIsBuiltIn(t *testing.T) {
|
func TestRabbitMQDriverDefinitionIsBuiltIn(t *testing.T) {
|
||||||
definition, ok := resolveDriverDefinition("rabbit-mq")
|
definition, ok := resolveDriverDefinition("rabbit-mq")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -489,6 +489,9 @@ var databaseFactories = map[string]databaseFactory{
|
|||||||
"qdrant": func() Database {
|
"qdrant": func() Database {
|
||||||
return &QdrantDB{}
|
return &QdrantDB{}
|
||||||
},
|
},
|
||||||
|
"mqtt": func() Database {
|
||||||
|
return &MQTTDB{}
|
||||||
|
},
|
||||||
"kafka": func() Database {
|
"kafka": func() Database {
|
||||||
return &KafkaDB{}
|
return &KafkaDB{}
|
||||||
},
|
},
|
||||||
@@ -535,6 +538,8 @@ func normalizeDatabaseType(dbType string) string {
|
|||||||
return "chroma"
|
return "chroma"
|
||||||
case "qdrantdb", "qdrant-db":
|
case "qdrantdb", "qdrant-db":
|
||||||
return "qdrant"
|
return "qdrant"
|
||||||
|
case "mqtt", "mqtts":
|
||||||
|
return "mqtt"
|
||||||
case "kafka", "apache-kafka", "apache_kafka":
|
case "kafka", "apache-kafka", "apache_kafka":
|
||||||
return "kafka"
|
return "kafka"
|
||||||
case "rabbitmq", "rabbit-mq", "rabbit_mq":
|
case "rabbitmq", "rabbit-mq", "rabbit_mq":
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var coreBuiltinDrivers = map[string]struct{}{
|
|||||||
"postgres": {},
|
"postgres": {},
|
||||||
"chroma": {},
|
"chroma": {},
|
||||||
"qdrant": {},
|
"qdrant": {},
|
||||||
|
"mqtt": {},
|
||||||
"kafka": {},
|
"kafka": {},
|
||||||
"rabbitmq": {},
|
"rabbitmq": {},
|
||||||
}
|
}
|
||||||
@@ -81,6 +82,8 @@ func normalizeRuntimeDriverType(driverType string) string {
|
|||||||
return "chroma"
|
return "chroma"
|
||||||
case "qdrantdb", "qdrant-db":
|
case "qdrantdb", "qdrant-db":
|
||||||
return "qdrant"
|
return "qdrant"
|
||||||
|
case "mqtt", "mqtts":
|
||||||
|
return "mqtt"
|
||||||
case "apache-iotdb", "apache_iotdb", "iotdb":
|
case "apache-iotdb", "apache_iotdb", "iotdb":
|
||||||
return "iotdb"
|
return "iotdb"
|
||||||
case "kafka", "apache-kafka", "apache_kafka":
|
case "kafka", "apache-kafka", "apache_kafka":
|
||||||
@@ -148,6 +151,8 @@ func driverDisplayName(driverType string) string {
|
|||||||
return "Chroma"
|
return "Chroma"
|
||||||
case "qdrant":
|
case "qdrant":
|
||||||
return "Qdrant"
|
return "Qdrant"
|
||||||
|
case "mqtt":
|
||||||
|
return "MQTT"
|
||||||
case "kafka":
|
case "kafka":
|
||||||
return "Kafka"
|
return "Kafka"
|
||||||
case "rabbitmq":
|
case "rabbitmq":
|
||||||
|
|||||||
1191
internal/db/mqtt_impl.go
Normal file
1191
internal/db/mqtt_impl.go
Normal file
File diff suppressed because it is too large
Load Diff
211
internal/db/mqtt_impl_test.go
Normal file
211
internal/db/mqtt_impl_test.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeMQTTConfigParsesURIAndParams(t *testing.T) {
|
||||||
|
config := normalizeMQTTConfig(connection.ConnectionConfig{
|
||||||
|
URI: "mqtt://user:secret@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&tls=true&skip_verify=true",
|
||||||
|
ConnectionParams: "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&qos=1&retain=false&cleanSession=false&fetchWaitMs=3500",
|
||||||
|
})
|
||||||
|
|
||||||
|
if config.Host != "127.0.0.1" || config.Port != 1883 {
|
||||||
|
t.Fatalf("unexpected mqtt host/port: %#v", config)
|
||||||
|
}
|
||||||
|
if config.User != "user" || config.Password != "secret" {
|
||||||
|
t.Fatalf("unexpected mqtt credentials: %#v", config)
|
||||||
|
}
|
||||||
|
if config.Database != "devices/+/telemetry" {
|
||||||
|
t.Fatalf("unexpected mqtt default topic: %q", config.Database)
|
||||||
|
}
|
||||||
|
if !config.UseSSL || config.SSLMode != "skip-verify" {
|
||||||
|
t.Fatalf("unexpected mqtt tls settings: %#v", config)
|
||||||
|
}
|
||||||
|
if config.Topology != "cluster" {
|
||||||
|
t.Fatalf("unexpected mqtt topology: %q", config.Topology)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := mqttConnectionParams(config)
|
||||||
|
if params.Get("topics") != "devices/+/telemetry,$SYS/#" {
|
||||||
|
t.Fatalf("unexpected mqtt topics param: %#v", params)
|
||||||
|
}
|
||||||
|
if params.Get("qos") != "1" || params.Get("fetchWaitMs") != "3500" {
|
||||||
|
t.Fatalf("unexpected mqtt params: %#v", params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMQTTQueryExecAndColumns(t *testing.T) {
|
||||||
|
fakeRuntime := &fakeMQTTRuntime{
|
||||||
|
fetchResponses: map[string][]mqttMessageRecord{
|
||||||
|
"devices/+/telemetry": {
|
||||||
|
{
|
||||||
|
Topic: "devices/device-001/telemetry",
|
||||||
|
QoS: 1,
|
||||||
|
Retained: false,
|
||||||
|
Duplicate: false,
|
||||||
|
MessageID: 12,
|
||||||
|
Payload: []byte(`{"event":"created","meta":{"source":"sensor"}}`),
|
||||||
|
Decoded: map[string]interface{}{"event": "created", "meta": map[string]interface{}{"source": "sensor"}},
|
||||||
|
Encoding: "json",
|
||||||
|
ReceivedAt: time.Date(2026, 6, 14, 11, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Topic: "devices/device-002/telemetry",
|
||||||
|
QoS: 1,
|
||||||
|
Retained: true,
|
||||||
|
Duplicate: false,
|
||||||
|
MessageID: 13,
|
||||||
|
Payload: []byte("plain-text"),
|
||||||
|
Decoded: "plain-text",
|
||||||
|
Encoding: "text",
|
||||||
|
ReceivedAt: time.Date(2026, 6, 14, 11, 0, 1, 0, time.UTC),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
originalFactory := newMQTTRuntime
|
||||||
|
newMQTTRuntime = func(config connection.ConnectionConfig) (mqttRuntime, error) {
|
||||||
|
return fakeRuntime, nil
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
newMQTTRuntime = originalFactory
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &MQTTDB{}
|
||||||
|
if err := client.Connect(connection.ConnectionConfig{
|
||||||
|
Type: "mqtt",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 1883,
|
||||||
|
Database: "devices/+/telemetry",
|
||||||
|
ConnectionParams: "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&qos=1&fetchWaitMs=2500",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Connect failed: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
rows, columns, err := client.Query(`SHOW TOPICS LIMIT 2`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SHOW TOPICS failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 || rows[0]["topic"] != "devices/+/telemetry" {
|
||||||
|
t.Fatalf("unexpected mqtt topic rows: %#v", rows)
|
||||||
|
}
|
||||||
|
if !containsString(columns, "wildcard") {
|
||||||
|
t.Fatalf("expected wildcard column, got %v", columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _, err = client.Query(`DESCRIBE TOPIC "devices/+/telemetry"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DESCRIBE TOPIC failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0]["configured"] != true || rows[0]["default_qos"] != 1 {
|
||||||
|
t.Fatalf("unexpected mqtt describe rows: %#v", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, columns, err = client.Query(`SELECT * FROM "devices/+/telemetry" LIMIT 1 OFFSET 1`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SELECT topic failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(fakeRuntime.fetchRequests) == 0 || fakeRuntime.fetchRequests[len(fakeRuntime.fetchRequests)-1].Offset != 1 {
|
||||||
|
t.Fatalf("expected mqtt fetch offset 1, got %#v", fakeRuntime.fetchRequests)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0]["payload"] != "plain-text" || rows[0]["payload_encoding"] != "text" {
|
||||||
|
t.Fatalf("unexpected mqtt message rows: %#v", rows)
|
||||||
|
}
|
||||||
|
if !containsString(columns, "payload_encoding") {
|
||||||
|
t.Fatalf("expected payload_encoding column, got %v", columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := client.Exec(`{"publish":"devices/device-001/telemetry","payload":{"id":1},"qos":2,"retain":true}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mqtt publish failed: %v", err)
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("unexpected affected rows: %d", affected)
|
||||||
|
}
|
||||||
|
if len(fakeRuntime.published) != 1 {
|
||||||
|
t.Fatalf("expected one mqtt publish call, got %#v", fakeRuntime.published)
|
||||||
|
}
|
||||||
|
if fakeRuntime.published[0].Topic != "devices/device-001/telemetry" || fakeRuntime.published[0].QoS != 2 || !fakeRuntime.published[0].Retain {
|
||||||
|
t.Fatalf("unexpected mqtt publish command: %#v", fakeRuntime.published[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
columnDefs, err := client.GetColumns(mqttSyntheticDatabase, "devices/+/telemetry")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetColumns failed: %v", err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(columnDefs))
|
||||||
|
for _, col := range columnDefs {
|
||||||
|
names = append(names, col.Name)
|
||||||
|
}
|
||||||
|
joined := strings.Join(names, ",")
|
||||||
|
for _, want := range []string{"topic", "payload.meta.source", "payload_encoding"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("expected mqtt column %q in %s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databases, err := client.GetDatabases()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDatabases failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(databases, []string{mqttSyntheticDatabase}) {
|
||||||
|
t.Fatalf("unexpected mqtt database list: %#v", databases)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := client.GetTables(mqttSyntheticDatabase)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTables failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tables, []string{"$SYS/#", "devices/+/telemetry"}) {
|
||||||
|
t.Fatalf("unexpected mqtt topic list: %#v", tables)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := client.Query(`SELECT COUNT(*) FROM "devices/+/telemetry"`); err == nil || !strings.Contains(err.Error(), "COUNT(*)") {
|
||||||
|
t.Fatalf("expected COUNT(*) to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeMQTTRuntime struct {
|
||||||
|
fetchResponses map[string][]mqttMessageRecord
|
||||||
|
fetchRequests []mqttFetchRequest
|
||||||
|
published []mqttPublishCommand
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMQTTRuntime) Close() error {
|
||||||
|
f.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMQTTRuntime) Ping(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMQTTRuntime) FetchMessages(ctx context.Context, request mqttFetchRequest) ([]mqttMessageRecord, error) {
|
||||||
|
f.fetchRequests = append(f.fetchRequests, request)
|
||||||
|
items := append([]mqttMessageRecord(nil), f.fetchResponses[request.Topic]...)
|
||||||
|
if request.Offset > 0 {
|
||||||
|
if request.Offset >= len(items) {
|
||||||
|
return []mqttMessageRecord{}, nil
|
||||||
|
}
|
||||||
|
items = items[request.Offset:]
|
||||||
|
}
|
||||||
|
if request.Limit > 0 && len(items) > request.Limit {
|
||||||
|
items = items[:request.Limit]
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMQTTRuntime) Publish(ctx context.Context, command mqttPublishCommand) (int64, error) {
|
||||||
|
f.published = append(f.published, command)
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user