diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index d97172c..429b214 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") ? "" : "root";', + 'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "kafka" || type === "rabbitmq") ? "" : "root";', ); expect(source).toContain( - 'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka") ? "未开启认证可留空" : undefined}', + 'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka" || dbType === "rabbitmq") ? "未开启认证可留空" : undefined}', ); expect(source).toContain('label="显示数据库 (留空显示全部)"'); }); @@ -89,6 +89,19 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain('label="默认 Topic(可选)"'); }); + it('exposes RabbitMQ in the create-connection picker with management-api and vhost defaults', () => { + expect(source).toContain("case 'rabbitmq':"); + expect(source).toContain('return 15672;'); + expect(source).toContain('rabbitmq: ["rabbitmq", "http", "https"]'); + expect(source).toContain("key: 'rabbitmq'"); + expect(source).toContain("name: 'RabbitMQ'"); + expect(source).toContain('dbType === "rabbitmq"'); + expect(source).toContain("return 'Management API / Virtual Host / Queue';"); + expect(source).toContain('return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";'); + expect(source).toContain('return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq";'); + expect(source).toContain('label="默认 Virtual Host(可选)"'); + }); + it('exposes GaussDB in the create-connection picker with PostgreSQL-family defaults', () => { expect(source).toContain("case 'gaussdb':"); expect(source).toContain('return 5432;'); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index f9f4f1c..538a2d9 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -420,6 +420,7 @@ const ConnectionModal: React.FC<{ const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle"; const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle; const isKafka = dbType === "kafka"; + const isRabbitMQ = dbType === "rabbitmq"; const supportsConnectionParams = supportsConnectionParamsForType(dbType); const isSSLType = supportsSSLForType(dbType); const supportsSSLCAPath = supportsSSLCAPathForType(dbType); @@ -1690,6 +1691,46 @@ const ConnectionModal: React.FC<{ }; } + if (type === "rabbitmq") { + const defaultPort = getDefaultPortByType(type); + const parsed = parseSingleHostUri( + trimmedUri, + ["rabbitmq", "http", "https"], + defaultPort, + ); + if (!parsed) { + return null; + } + const lowerUri = trimmedUri.toLowerCase(); + const tlsEnabled = + lowerUri.startsWith("https://") || + 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 timeoutValue = Number(parsed.params.get("timeout")); + return { + host: parsed.host, + port: parsed.port, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + useSSL: tlsEnabled, + sslMode: tlsEnabled ? (skipVerify ? "skip-verify" : "required") : "disable", + ...extractSSLPathValuesFromParams(parsed.params, type), + connectionParams: serializeConnectionParams(parsed.params), + timeout: + Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue)) + : undefined, + }; + } + if (type === "clickhouse") { const httpValues = parseClickHouseHTTPUriToValues(trimmedUri); if (httpValues) { @@ -1941,6 +1982,9 @@ const ConnectionModal: React.FC<{ 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"; } + if (dbType === "rabbitmq") { + return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30"; + } if (dbType === "redis") { return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster"; } @@ -1998,6 +2042,8 @@ const ConnectionModal: React.FC<{ return "fetchSize=1024&timeZone=Asia%2FShanghai"; case "kafka": return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest"; + case "rabbitmq": + return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq"; default: return "key=value&another=value"; } @@ -2090,6 +2136,28 @@ const ConnectionModal: React.FC<{ return `kafka://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`; } + if (type === "rabbitmq") { + const address = toAddress(host, port, defaultPort); + const params = new URLSearchParams(); + 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 vhostPath = database ? `/${encodeURIComponent(database)}` : ""; + const query = params.toString(); + return `rabbitmq://${encodedAuth}${address}${vhostPath}${query ? `?${query}` : ""}`; + } + if (type === "redis") { return buildRedisUriFromValues(values); } @@ -3847,7 +3915,7 @@ const ConnectionModal: React.FC<{ }); } else if (type !== "custom") { const defaultUser = - type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "kafka") ? "" : "root"; + type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "kafka" || type === "rabbitmq") ? "" : "root"; const sslCapableType = supportsSSLForType(type); setUseSSL(false); setUseHttpTunnel(false); @@ -4993,6 +5061,22 @@ const ConnectionModal: React.FC<{ ), })} + {dbType === "rabbitmq" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + {(dbType === "oracle" || isOceanBaseOracle) && renderConfigSectionCard({ sectionKey: "service", @@ -5204,13 +5288,13 @@ const ConnectionModal: React.FC<{ name="user" label="用户名" rules={ - (dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka") + (dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka" || dbType === "rabbitmq") ? [] : [createUriAwareRequiredRule("请输入用户名")] } style={{ marginBottom: 0 }} > - + { expect(markup).toContain('>Kf'); }); + it('includes RabbitMQ in the selectable database icons', () => { + expect(DB_ICON_TYPES).toContain('rabbitmq'); + expect(getDbIconLabel('rabbitmq')).toBe('RabbitMQ'); + const markup = renderToStaticMarkup(<>{getDbIcon('rabbitmq', undefined, 22)}); + expect(markup).toContain('>RM'); + }); + it('includes GaussDB in the selectable database icons', () => { expect(DB_ICON_TYPES).toContain('gaussdb'); expect(getDbIconLabel('gaussdb')).toBe('GaussDB'); diff --git a/frontend/src/components/DatabaseIcons.tsx b/frontend/src/components/DatabaseIcons.tsx index bc14bce..2cb472c 100644 --- a/frontend/src/components/DatabaseIcons.tsx +++ b/frontend/src/components/DatabaseIcons.tsx @@ -53,6 +53,7 @@ const DB_DEFAULT_COLORS: Record = { tdengine: '#2962FF', iotdb: '#0F766E', kafka: '#F97316', + rabbitmq: '#FF6B35', chroma: '#7C3AED', qdrant: '#DC244C', diros: '#0050B3', @@ -196,6 +197,9 @@ const IoTDBIcon: React.FC = ({ size = 16, color }) => ( const KafkaIcon: React.FC = ({ size = 16, color }) => ( ); +const RabbitMQIcon: React.FC = ({ size = 16, color }) => ( + +); const ChromaIcon: React.FC = ({ size = 16, color }) => ( ); @@ -259,6 +263,7 @@ const DB_ICON_MAP: Record> = { tdengine: TDengineIcon, iotdb: IoTDBIcon, kafka: KafkaIcon, + rabbitmq: RabbitMQIcon, chroma: ChromaIcon, qdrant: QdrantIcon, elasticsearch: ElasticsearchIcon, @@ -269,7 +274,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', 'chroma', 'qdrant', 'elasticsearch', 'custom', + 'kingbase', 'dameng', 'vastbase', 'opengauss', 'gaussdb', 'goldendb', 'highgo', 'iris', 'tdengine', 'iotdb', 'kafka', 'rabbitmq', 'chroma', 'qdrant', 'elasticsearch', 'custom', ]; /** 该类型是否有品牌 SVG 文件 */ @@ -291,7 +296,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', + vastbase: 'VastBase', opengauss: 'OpenGauss', gaussdb: 'GaussDB', goldendb: 'GoldenDB', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', iotdb: 'Apache IoTDB', kafka: 'Kafka', rabbitmq: 'RabbitMQ', chroma: 'Chroma', qdrant: 'Qdrant', elasticsearch: 'Elasticsearch', diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx new file mode 100644 index 0000000..a5e2504 --- /dev/null +++ b/frontend/src/components/MessagePublishModal.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Form, Input, Modal, Select, Space, Typography, message } from 'antd'; + +import { DBQuery } from '../../wailsjs/go/app/App'; +import type { SavedConnection } from '../types'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { + buildMessagePublishCommand, + createDefaultMessagePublishDraft, + getMessagePublishPresentation, + type MessagePublishDraft, +} from '../utils/messagePublish'; + +const { Text } = Typography; +const { TextArea } = Input; + +export type MessagePublishModalProps = { + open: boolean; + connection: SavedConnection | null; + executionDbName?: string; + defaultDestination?: string; + onCancel: () => void; + onSuccess?: (result: { destination: string; affectedRows: number; commandText: string }) => void; +}; + +const MessagePublishModal: React.FC = ({ + open, + connection, + executionDbName = '', + defaultDestination = '', + onCancel, + onSuccess, +}) => { + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const presentation = useMemo( + () => getMessagePublishPresentation(connection?.config), + [connection], + ); + + useEffect(() => { + if (!open || !connection) return; + form.setFieldsValue( + createDefaultMessagePublishDraft( + connection.config, + defaultDestination, + ), + ); + }, [connection, defaultDestination, form, open]); + + useEffect(() => { + if (open) return; + form.resetFields(); + setSubmitting(false); + }, [form, open]); + + const handleSubmit = async () => { + if (!connection) return; + + let values: MessagePublishDraft; + try { + values = await form.validateFields(); + } catch { + return; + } + + let command; + try { + command = buildMessagePublishCommand(connection.config, values); + } catch (error: any) { + void message.error(error?.message || '构造发送命令失败'); + return; + } + + setSubmitting(true); + try { + const res = await DBQuery( + buildRpcConnectionConfig(connection.config) as any, + executionDbName, + command.commandText, + ); + if (!res?.success) { + void message.error(`发送失败: ${res?.message || '未知错误'}`); + return; + } + + const affectedRows = Number((res.data as any)?.affectedRows); + onSuccess?.({ + destination: command.destinationLabel, + affectedRows: Number.isFinite(affectedRows) ? affectedRows : 0, + commandText: command.commandText, + }); + } catch (error: any) { + void message.error(`发送失败: ${error?.message || String(error)}`); + } finally { + setSubmitting(false); + } + }; + + return ( + { void handleSubmit(); }} + okText="发送" + confirmLoading={submitting} + width={720} + destroyOnHidden + maskClosable={!submitting} + > + + + + + form={form} + layout="vertical" + initialValues={createDefaultMessagePublishDraft(connection?.config, defaultDestination)} + > + + + + + {presentation.showExchange && ( + + + + )} + + {presentation.showRoutingKey && ( + + + + )} + + {presentation.showKey && ( + + + + + + + + )} + + +