mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-20 21:43:56 +08:00
✨ feat(mqtt): 新增 MQTT 数据源连接与测试发消息支持
This commit is contained in:
@@ -18,6 +18,7 @@ export const normalizeDriverType = (value: string): string => {
|
||||
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
|
||||
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
|
||||
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 === 'rabbit-mq' || normalized === 'rabbit_mq') return 'rabbitmq';
|
||||
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') {
|
||||
return {
|
||||
kind: 'generic-sql',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
|
||||
elasticsearch: ["http", "https"],
|
||||
chroma: ["http", "https", "chroma"],
|
||||
qdrant: ["http", "https", "qdrant"],
|
||||
mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"],
|
||||
rabbitmq: ["rabbitmq", "http", "https"],
|
||||
};
|
||||
|
||||
@@ -29,6 +30,8 @@ const normalizeConnectionType = (type: string) =>
|
||||
case "greatdb":
|
||||
case "gdb":
|
||||
return "goldendb";
|
||||
case "mqtts":
|
||||
return "mqtt";
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
@@ -59,6 +62,7 @@ const sslSupportedTypes = new Set([
|
||||
"elasticsearch",
|
||||
"chroma",
|
||||
"qdrant",
|
||||
"mqtt",
|
||||
"kafka",
|
||||
"rabbitmq",
|
||||
]);
|
||||
@@ -87,6 +91,7 @@ const sslCAPathSupportedTypes = new Set([
|
||||
"elasticsearch",
|
||||
"chroma",
|
||||
"qdrant",
|
||||
"mqtt",
|
||||
"kafka",
|
||||
"rabbitmq",
|
||||
]);
|
||||
@@ -109,6 +114,7 @@ const sslClientCertificateSupportedTypes = new Set([
|
||||
"gaussdb",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"mqtt",
|
||||
"kafka",
|
||||
"rabbitmq",
|
||||
]);
|
||||
@@ -161,5 +167,6 @@ export const supportsConnectionParamsForType = (type: string) =>
|
||||
type === "elasticsearch" ||
|
||||
type === "chroma" ||
|
||||
type === "qdrant" ||
|
||||
type === "mqtt" ||
|
||||
type === "kafka" ||
|
||||
type === "rabbitmq";
|
||||
|
||||
@@ -64,6 +64,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
|
||||
{
|
||||
label: '消息队列',
|
||||
items: [
|
||||
{ key: 'mqtt', name: 'MQTT' },
|
||||
{ key: 'kafka', name: 'Kafka' },
|
||||
{ key: 'rabbitmq', name: 'RabbitMQ' },
|
||||
],
|
||||
@@ -123,6 +124,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
|
||||
return 8000;
|
||||
case 'qdrant':
|
||||
return 6333;
|
||||
case 'mqtt':
|
||||
return 1883;
|
||||
case 'kafka':
|
||||
return 9092;
|
||||
case 'rabbitmq':
|
||||
@@ -159,6 +162,8 @@ export const getConnectionTypeHint = (type: string): string => {
|
||||
return 'Collection 浏览、向量搜索和 Payload 过滤';
|
||||
case 'iotdb':
|
||||
return 'Storage Group / Device / Timeseries';
|
||||
case 'mqtt':
|
||||
return 'Broker / Topic Filter / QoS';
|
||||
case 'kafka':
|
||||
return 'Broker / Topic / Consumer Group';
|
||||
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', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'kafka' })).toMatchObject({
|
||||
type: 'kafka',
|
||||
|
||||
@@ -35,6 +35,9 @@ const normalizeDataSourceToken = (raw: string): string => {
|
||||
case 'qdrantdb':
|
||||
case 'qdrant-db':
|
||||
return 'qdrant';
|
||||
case 'mqtt':
|
||||
case 'mqtts':
|
||||
return 'mqtt';
|
||||
case 'apache-iotdb':
|
||||
case 'apache_iotdb':
|
||||
return 'iotdb';
|
||||
@@ -121,9 +124,9 @@ const COPY_INSERT_TYPES = new Set([
|
||||
]);
|
||||
|
||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'kafka', 'rabbitmq']);
|
||||
const MESSAGE_PUBLISH_TYPES = new Set(['kafka', 'rabbitmq']);
|
||||
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'mqtt', 'kafka', 'rabbitmq']);
|
||||
const MESSAGE_PUBLISH_TYPES = new Set(['mqtt', 'kafka', 'rabbitmq']);
|
||||
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'mqtt']);
|
||||
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||
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', () => {
|
||||
const result = buildMessagePublishCommand(
|
||||
{ type: 'rabbitmq', connectionParams: 'defaultQueue=orders.queue&exchange=events.topic' },
|
||||
|
||||
@@ -15,6 +15,8 @@ export type MessagePublishDraft = {
|
||||
destination: string;
|
||||
exchange?: string;
|
||||
routingKey?: string;
|
||||
qos?: number;
|
||||
retain?: boolean;
|
||||
keyMode?: MessagePublishValueMode;
|
||||
key?: string;
|
||||
bodyMode?: MessagePublishValueMode;
|
||||
@@ -40,6 +42,8 @@ export type MessagePublishPresentation = {
|
||||
showExchange: boolean;
|
||||
showRoutingKey: boolean;
|
||||
showProperties: boolean;
|
||||
showQos: boolean;
|
||||
showRetain: boolean;
|
||||
};
|
||||
|
||||
const normalizeMode = (value: unknown, fallback: MessagePublishValueMode): MessagePublishValueMode => {
|
||||
@@ -138,6 +142,9 @@ const resolveDefaultDestination = (config: ConnectionLike, explicitDestination:
|
||||
if (resolvedType === 'kafka') {
|
||||
return String(config?.database || '').trim();
|
||||
}
|
||||
if (resolvedType === 'mqtt') {
|
||||
return String(config?.database || params.get('defaultTopic') || params.get('topic') || '').trim();
|
||||
}
|
||||
if (resolvedType === 'rabbitmq') {
|
||||
return String(params.get('defaultQueue') || params.get('queue') || '').trim();
|
||||
}
|
||||
@@ -161,6 +168,25 @@ export const getMessagePublishPresentation = (
|
||||
showExchange: true,
|
||||
showRoutingKey: 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,
|
||||
showRoutingKey: 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 {
|
||||
destination: resolvedDestination,
|
||||
keyMode: 'text',
|
||||
@@ -218,6 +258,27 @@ export const buildMessagePublishCommand = (
|
||||
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') {
|
||||
const params = resolveConnectionParams(config);
|
||||
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;');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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) {
|
||||
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)};`;
|
||||
|
||||
@@ -59,6 +59,11 @@ describe('quoteQualifiedIdent', () => {
|
||||
.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', () => {
|
||||
expect(quoteQualifiedIdent('kafka', '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) => {
|
||||
const raw = (ident || '').trim();
|
||||
if (!raw) return raw;
|
||||
if (['kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
|
||||
if (['mqtt', 'kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
|
||||
return quoteIdentPart(dbType, raw);
|
||||
}
|
||||
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('sqlDialect', () => {
|
||||
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
|
||||
expect(resolveSqlDialect('Apache-IoTDB')).toBe('iotdb');
|
||||
expect(resolveSqlDialect('custom', 'apache_iotdb')).toBe('iotdb');
|
||||
expect(resolveSqlDialect('MQTTS')).toBe('mqtt');
|
||||
expect(resolveSqlDialect('custom', 'mqtts')).toBe('mqtt');
|
||||
expect(resolveSqlDialect('Apache-Kafka')).toBe('kafka');
|
||||
expect(resolveSqlDialect('custom', 'apache_kafka')).toBe('kafka');
|
||||
expect(resolveSqlDialect('Rabbit-MQ')).toBe('rabbitmq');
|
||||
@@ -75,6 +77,11 @@ describe('sqlDialect', () => {
|
||||
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', () => {
|
||||
expect(resolveSqlKeywords('kafka')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
|
||||
expect(resolveSqlKeywords('kafka')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
|
||||
|
||||
@@ -29,6 +29,7 @@ export type SqlDialect =
|
||||
| 'clickhouse'
|
||||
| 'tdengine'
|
||||
| 'iotdb'
|
||||
| 'mqtt'
|
||||
| 'kafka'
|
||||
| 'rabbitmq'
|
||||
| 'mongodb'
|
||||
@@ -137,6 +138,9 @@ export const resolveSqlDialect = (
|
||||
case 'apache-iotdb':
|
||||
case 'apache_iotdb':
|
||||
return 'iotdb';
|
||||
case 'mqtt':
|
||||
case 'mqtts':
|
||||
return 'mqtt';
|
||||
case 'kafka':
|
||||
case 'apache-kafka':
|
||||
case 'apache_kafka':
|
||||
@@ -169,6 +173,7 @@ export const resolveSqlDialect = (
|
||||
if (source.includes('clickhouse')) return 'clickhouse';
|
||||
if (source.includes('tdengine')) return 'tdengine';
|
||||
if (source.includes('iotdb')) return 'iotdb';
|
||||
if (source.includes('mqtt')) return 'mqtt';
|
||||
if (source.includes('kafka')) return 'kafka';
|
||||
if (source.includes('rabbitmq') || source.includes('rabbit-mq') || source.includes('rabbit_mq')) return 'rabbitmq';
|
||||
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
|
||||
@@ -623,6 +628,15 @@ const IOTDB_KEYWORDS = [
|
||||
'COMPRESSION',
|
||||
];
|
||||
|
||||
const MQTT_KEYWORDS = [
|
||||
'SHOW TOPICS',
|
||||
'DESCRIBE TOPIC',
|
||||
'CONSUME',
|
||||
'FROM',
|
||||
'LIMIT',
|
||||
'OFFSET',
|
||||
];
|
||||
|
||||
const KAFKA_KEYWORDS = [
|
||||
'SHOW TOPICS',
|
||||
'SHOW TOPIC',
|
||||
@@ -658,6 +672,7 @@ export const resolveSqlKeywords = (dbType: string): string[] => {
|
||||
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
|
||||
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
|
||||
if (dialect === 'iotdb') return unique([...COMMON_KEYWORDS, ...IOTDB_KEYWORDS]);
|
||||
if (dialect === 'mqtt') return unique([...COMMON_KEYWORDS, ...MQTT_KEYWORDS]);
|
||||
if (dialect === 'kafka') return unique([...COMMON_KEYWORDS, ...KAFKA_KEYWORDS]);
|
||||
if (dialect === 'rabbitmq') return unique([...COMMON_KEYWORDS, ...RABBITMQ_KEYWORDS]);
|
||||
return COMMON_KEYWORDS;
|
||||
|
||||
Reference in New Issue
Block a user