import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; const { Meta } = Card; const { Text } = Typography; const MAX_URI_LENGTH = 4096; const MAX_URI_HOSTS = 32; const MAX_TIMEOUT_SECONDS = 3600; const CONNECTION_MODAL_WIDTH = 960; const CONNECTION_MODAL_BODY_HEIGHT = 620; const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)'; const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)'; const getDefaultPortByType = (type: string) => { switch (type) { case 'mysql': return 3306; case 'doris': case 'diros': return 9030; case 'sphinx': return 9306; case 'clickhouse': return 9000; case 'postgres': return 5432; case 'redis': return 6379; case 'tdengine': return 6041; case 'oracle': return 1521; case 'dameng': return 5236; case 'kingbase': return 54321; case 'sqlserver': return 1433; case 'mongodb': return 27017; case 'highgo': return 5866; case 'mariadb': return 3306; case 'vastbase': return 5432; case 'sqlite': return 0; case 'duckdb': return 0; default: return 3306; } }; const singleHostUriSchemesByType: Record = { postgres: ['postgresql', 'postgres'], clickhouse: ['clickhouse'], oracle: ['oracle'], sqlserver: ['sqlserver'], redis: ['redis'], tdengine: ['tdengine'], dameng: ['dameng', 'dm'], kingbase: ['kingbase'], highgo: ['highgo'], vastbase: ['vastbase'], }; const sslSupportedTypes = new Set([ 'mysql', 'mariadb', 'diros', 'sphinx', 'dameng', 'clickhouse', 'postgres', 'sqlserver', 'oracle', 'kingbase', 'highgo', 'vastbase', 'mongodb', 'redis', 'tdengine', ]); const supportsSSLForType = (type: string) => sslSupportedTypes.has(String(type || '').trim().toLowerCase()); const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb'; type DriverStatusSnapshot = { type: string; name: string; connectable: boolean; message?: string; }; const normalizeDriverType = (value: string): string => { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'postgresql') return 'postgres'; if (normalized === 'doris') return 'diros'; return normalized; }; const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null; onOpenDriverManager?: () => void; }> = ({ open, onClose, initialValues, onOpenDriverManager }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [useSSL, setUseSSL] = useState(false); const [useSSH, setUseSSH] = useState(false); const [useProxy, setUseProxy] = useState(false); const [useHttpTunnel, setUseHttpTunnel] = useState(false); const [dbType, setDbType] = useState('mysql'); const [step, setStep] = useState(1); // 1: Select Type, 2: Configure const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1 const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network'>('basic'); const [activeNetworkConfig, setActiveNetworkConfig] = useState<'ssl' | 'ssh' | 'proxy' | 'httpTunnel'>('ssl'); const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [testErrorLogOpen, setTestErrorLogOpen] = useState(false); const [dbList, setDbList] = useState([]); const [redisDbList, setRedisDbList] = useState([]); // Redis databases 0-15 const [mongoMembers, setMongoMembers] = useState([]); const [discoveringMembers, setDiscoveringMembers] = useState(false); const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null); const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null); const [driverStatusMap, setDriverStatusMap] = useState>({}); const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const [selectingDbFile, setSelectingDbFile] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); const theme = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single'; const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; const redisTopology = Form.useWatch('redisTopology', form) || 'single'; const isMySQLLike = dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx'; const isSSLType = supportsSSLForType(dbType); const sslHintText = isMySQLLike ? '当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL;本地自签证书场景可先用 Preferred 或 Skip Verify。' : dbType === 'dameng' ? '达梦驱动启用 SSL 需要客户端证书与私钥路径(sslCertPath / sslKeyPath)。' : dbType === 'sqlserver' ? 'SQL Server 推荐在生产环境使用 Required,并关闭 TrustServerCertificate。' : dbType === 'mongodb' ? 'MongoDB 可通过 TLS 保护连接,证书校验异常时可先用 Skip Verify 验证连通性。' : '建议优先使用 Required;仅在测试环境或自签证书场景使用 Skip Verify。'; const getSectionBg = (darkHex: string) => { if (!darkMode) { return `rgba(245, 245, 245, ${Math.max(effectiveOpacity, 0.92)})`; } const hex = darkHex.replace('#', ''); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${Math.max(effectiveOpacity, 0.82)})`; }; const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT; const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff'; const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff'; const tunnelSectionStyle: React.CSSProperties = { padding: '12px', background: getSectionBg('#2a2a2a'), borderRadius: 6, marginTop: 12, border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)', }; const modalShellStyle = useMemo(() => ({ background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)', backdropFilter: darkMode ? 'blur(18px)' : 'none', }), [darkMode]); const modalInnerSectionStyle = useMemo(() => ({ padding: 14, borderRadius: 14, border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', }), [darkMode]); const modalMutedTextStyle = useMemo(() => ({ color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6, }), [darkMode]); const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
{icon}
{title}
{description}
); const getConnectionOptionCardStyle = (_enabled: boolean): React.CSSProperties => ({ padding: '12px 14px', borderRadius: 14, border: '1px solid transparent', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)', boxShadow: darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)', transition: 'all 120ms ease', }); const fetchDriverStatusMap = async (): Promise> => { const result: Record = {}; const res = await GetDriverStatusList('', ''); if (!res?.success) { return result; } const data = (res?.data || {}) as any; const drivers = Array.isArray(data.drivers) ? data.drivers : []; drivers.forEach((item: any) => { const type = normalizeDriverType(String(item.type || '').trim()); if (!type) return; result[type] = { type, name: String(item.name || item.type || type).trim(), connectable: !!item.connectable, message: String(item.message || '').trim() || undefined, }; }); return result; }; const refreshDriverStatus = async () => { try { const next = await fetchDriverStatusMap(); setDriverStatusMap(next); } catch { setDriverStatusMap({}); } finally { setDriverStatusLoaded(true); } }; const resolveDriverUnavailableReason = async (type: string): Promise => { const normalized = normalizeDriverType(type); if (!normalized || normalized === 'custom') { return ''; } let snapshot = driverStatusMap; if (!snapshot[normalized]) { snapshot = await fetchDriverStatusMap(); setDriverStatusMap(snapshot); } const status = snapshot[normalized]; if (!status || status.connectable) { return ''; } return status.message || `${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装`; }; const promptInstallDriver = (driverType: string, reason: string) => { const normalized = normalizeDriverType(driverType); const snapshot = driverStatusMap[normalized]; const driverName = snapshot?.name || normalized || '当前'; Modal.confirm({ title: `${driverName} 驱动不可用`, content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`, okText: '去驱动管理安装', cancelText: '取消', onOk: () => { onOpenDriverManager?.(); }, }); }; const parseHostPort = (raw: string, defaultPort: number): { host: string; port: number } | null => { const text = String(raw || '').trim(); if (!text) { return null; } if (text.startsWith('[')) { const closingBracket = text.indexOf(']'); if (closingBracket > 0) { const host = text.slice(1, closingBracket).trim(); const portText = text.slice(closingBracket + 1).trim().replace(/^:/, ''); const parsedPort = Number(portText); return { host: host || 'localhost', port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort, }; } } const colonCount = (text.match(/:/g) || []).length; if (colonCount === 1) { const splitIndex = text.lastIndexOf(':'); const host = text.slice(0, splitIndex).trim(); const portText = text.slice(splitIndex + 1).trim(); const parsedPort = Number(portText); return { host: host || 'localhost', port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort, }; } return { host: text, port: defaultPort }; }; const toAddress = (host: string, port: number, defaultPort: number) => { const safeHost = String(host || '').trim() || 'localhost'; const safePort = Number.isFinite(Number(port)) && Number(port) > 0 ? Number(port) : defaultPort; return `${safeHost}:${safePort}`; }; const normalizeAddressList = (rawList: unknown, defaultPort: number): string[] => { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); const result: string[] = []; list.forEach((entry) => { const parsed = parseHostPort(String(entry || ''), defaultPort); if (!parsed) { return; } const normalized = toAddress(parsed.host, parsed.port, defaultPort); if (seen.has(normalized)) { return; } seen.add(normalized); result.push(normalized); }); return result; }; const isValidUriHostEntry = (entry: string): boolean => { const text = String(entry || '').trim(); if (!text) return false; if (text.length > 255) return false; // 拒绝明显的 DSN 片段或路径/空白,避免把非 URI 主机段误判为合法地址。 if (/[()\\/\s]/.test(text)) return false; return true; }; const normalizeMongoSrvHostList = (rawList: unknown, defaultPort: number): string[] => { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); const result: string[] = []; list.forEach((entry) => { const parsed = parseHostPort(String(entry || ''), defaultPort); if (!parsed?.host) { return; } const host = String(parsed.host).trim(); if (!host || seen.has(host)) { return; } seen.add(host); result.push(host); }); return result; }; const safeDecode = (text: string) => { try { return decodeURIComponent(text); } catch { return text; } }; const normalizeFileDbPath = (rawPath: string): string => { let pathText = String(rawPath || '').trim(); if (!pathText) { return ''; } // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { pathText = pathText.slice(1); } // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); if (legacyMatch?.[1]) { return legacyMatch[1]; } return pathText; }; const parseMultiHostUri = (uriText: string, expectedScheme: string) => { const prefix = `${expectedScheme}://`; if (!uriText.toLowerCase().startsWith(prefix)) { return null; } let rest = uriText.slice(prefix.length); const hashIndex = rest.indexOf('#'); if (hashIndex >= 0) { rest = rest.slice(0, hashIndex); } let queryText = ''; const queryIndex = rest.indexOf('?'); if (queryIndex >= 0) { queryText = rest.slice(queryIndex + 1); rest = rest.slice(0, queryIndex); } let pathText = ''; const slashIndex = rest.indexOf('/'); if (slashIndex >= 0) { pathText = rest.slice(slashIndex + 1); rest = rest.slice(0, slashIndex); } let hostText = rest; let username = ''; let password = ''; const atIndex = rest.lastIndexOf('@'); if (atIndex >= 0) { const userInfo = rest.slice(0, atIndex); hostText = rest.slice(atIndex + 1); const colonIndex = userInfo.indexOf(':'); if (colonIndex >= 0) { username = safeDecode(userInfo.slice(0, colonIndex)); password = safeDecode(userInfo.slice(colonIndex + 1)); } else { username = safeDecode(userInfo); } } const hosts = hostText .split(',') .map((item) => item.trim()) .filter(Boolean); return { username, password, hosts, database: safeDecode(pathText), params: new URLSearchParams(queryText), }; }; const parseSingleHostUri = ( uriText: string, expectedSchemes: string[], defaultPort: number, ): { host: string; port: number; username: string; password: string; database: string; params: URLSearchParams } | null => { let parsed: ReturnType | null = null; for (const scheme of expectedSchemes) { parsed = parseMultiHostUri(uriText, scheme); if (parsed) { break; } } 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); return { host: primary?.host || 'localhost', port: primary?.port || defaultPort, username: parsed.username, password: parsed.password, database: parsed.database || '', params: parsed.params, }; }; const parseUriToValues = (uriText: string, type: string): Record | null => { const trimmedUri = String(uriText || '').trim(); if (!trimmedUri) { return null; } if (trimmedUri.length > MAX_URI_LENGTH) { return null; } if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const mysqlDefaultPort = getDefaultPortByType(type); const parsed = parseMultiHostUri(trimmedUri, 'mysql') || parseMultiHostUri(trimmedUri, 'diros') || parseMultiHostUri(trimmedUri, 'doris'); 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, mysqlDefaultPort); if (!hostList.length) { return null; } const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort); const timeoutValue = Number(parsed.params.get('timeout')); const topology = String(parsed.params.get('topology') || '').toLowerCase(); const tlsValue = String(parsed.params.get('tls') || '').trim().toLowerCase(); const sslMode = tlsValue === 'true' ? 'required' : tlsValue === 'skip-verify' ? 'skip-verify' : tlsValue === 'preferred' ? 'preferred' : 'disable'; return { host: primary?.host || 'localhost', port: primary?.port || mysqlDefaultPort, user: parsed.username, password: parsed.password, database: parsed.database || '', useSSL: sslMode !== 'disable', sslMode, mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single', mysqlReplicaHosts: hostList.slice(1), timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 ? Math.min(3600, Math.trunc(timeoutValue)) : undefined, }; } if (isFileDatabaseType(type)) { const rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') .trim(); if (!rawPath) { return null; } return { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'redis') { const parsed = parseMultiHostUri(trimmedUri, 'redis') || parseMultiHostUri(trimmedUri, 'rediss'); 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, 6379); if (!hostList.length) { return null; } const primary = parseHostPort(hostList[0] || 'localhost:6379', 6379); const topologyParam = String(parsed.params.get('topology') || '').toLowerCase(); const dbText = String(parsed.database || '').trim().replace(/^\//, ''); const dbIndex = Number(dbText); const isRediss = trimmedUri.toLowerCase().startsWith('rediss://'); const skipVerifyText = String(parsed.params.get('skip_verify') || '').trim().toLowerCase(); const skipVerify = skipVerifyText === '1' || skipVerifyText === 'true' || skipVerifyText === 'yes' || skipVerifyText === 'on'; return { host: primary?.host || 'localhost', port: primary?.port || 6379, password: parsed.password || '', useSSL: isRediss, sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable', redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single', redisHosts: hostList.slice(1), redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0, }; } if (type === 'mongodb') { const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv'); 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 isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://'); const hostList = isSrv ? normalizeMongoSrvHostList(parsed.hosts, 27017) : normalizeAddressList(parsed.hosts, 27017); if (!hostList.length) { return null; } const primary = isSrv ? { host: hostList[0] || 'localhost', port: 27017 } : parseHostPort(hostList[0] || 'localhost:27017', 27017); const timeoutMs = Number(parsed.params.get('connectTimeoutMS') || parsed.params.get('serverSelectionTimeoutMS')); const tlsText = String(parsed.params.get('tls') || parsed.params.get('ssl') || '').trim().toLowerCase(); const tlsInsecureText = String(parsed.params.get('tlsInsecure') || parsed.params.get('sslInsecure') || '').trim().toLowerCase(); const tlsEnabled = tlsText === '1' || tlsText === 'true' || tlsText === 'yes' || tlsText === 'on'; const tlsInsecure = tlsInsecureText === '1' || tlsInsecureText === 'true' || tlsInsecureText === 'yes' || tlsInsecureText === 'on'; return { host: primary?.host || 'localhost', port: primary?.port || 27017, user: parsed.username, password: parsed.password, database: parsed.database || '', useSSL: tlsEnabled, sslMode: tlsEnabled ? (tlsInsecure ? 'skip-verify' : 'required') : 'disable', mongoTopology: hostList.length > 1 || !!parsed.params.get('replicaSet') ? 'replica' : 'single', mongoHosts: hostList.slice(1), mongoSrv: isSrv, mongoReplicaSet: parsed.params.get('replicaSet') || '', mongoAuthSource: parsed.params.get('authSource') || '', mongoReadPreference: parsed.params.get('readPreference') || 'primary', mongoAuthMechanism: parsed.params.get('authMechanism') || '', timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000)) : undefined, savePassword: true, }; } const singleHostSchemes = singleHostUriSchemesByType[type]; if (singleHostSchemes && singleHostSchemes.length > 0) { const parsed = parseSingleHostUri(trimmedUri, singleHostSchemes, getDefaultPortByType(type)); if (!parsed) { return null; } if (type === 'oracle' && !String(parsed.database || '').trim()) { // Oracle 需要显式 service name,避免 URI 解析后放过必填校验。 return null; } const parsedValues: Record = { host: parsed.host, port: parsed.port, user: parsed.username, password: parsed.password, database: parsed.database, }; if (supportsSSLForType(type)) { const normalizeBool = (raw: unknown) => { const text = String(raw ?? '').trim().toLowerCase(); return text === '1' || text === 'true' || text === 'yes' || text === 'on'; }; if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { const sslMode = String(parsed.params.get('sslmode') || '').trim().toLowerCase(); if (sslMode) { parsedValues.useSSL = sslMode !== 'disable' && sslMode !== 'false'; parsedValues.sslMode = sslMode === 'disable' || sslMode === 'false' ? 'disable' : 'required'; } } else if (type === 'sqlserver') { const encrypt = String(parsed.params.get('encrypt') || '').trim().toLowerCase(); const trust = String(parsed.params.get('TrustServerCertificate') || parsed.params.get('trustservercertificate') || '').trim().toLowerCase(); const encrypted = encrypt === 'true' || encrypt === 'mandatory' || encrypt === 'yes' || encrypt === '1' || encrypt === 'strict'; if (encrypted) { parsedValues.useSSL = true; parsedValues.sslMode = trust === 'true' || trust === '1' || trust === 'yes' ? 'skip-verify' : 'required'; } else if (encrypt) { parsedValues.useSSL = false; parsedValues.sslMode = 'disable'; } } else if (type === 'clickhouse') { const secure = String(parsed.params.get('secure') || parsed.params.get('tls') || '').trim().toLowerCase(); const skipVerify = normalizeBool(parsed.params.get('skip_verify')); if (secure) { parsedValues.useSSL = normalizeBool(secure); parsedValues.sslMode = skipVerify ? 'skip-verify' : (parsedValues.useSSL ? 'required' : 'disable'); } } else if (type === 'dameng') { const certPath = String( parsed.params.get('SSL_CERT_PATH') || parsed.params.get('ssl_cert_path') || parsed.params.get('sslCertPath') || '' ).trim(); const keyPath = String( parsed.params.get('SSL_KEY_PATH') || parsed.params.get('ssl_key_path') || parsed.params.get('sslKeyPath') || '' ).trim(); parsedValues.sslCertPath = certPath; parsedValues.sslKeyPath = keyPath; if (certPath || keyPath) { parsedValues.useSSL = true; parsedValues.sslMode = 'required'; } } else if (type === 'oracle') { const ssl = String(parsed.params.get('SSL') || parsed.params.get('ssl') || '').trim().toLowerCase(); const sslVerify = String( parsed.params.get('SSL VERIFY') || parsed.params.get('ssl verify') || parsed.params.get('SSL_VERIFY') || parsed.params.get('ssl_verify') || '' ).trim().toLowerCase(); if (ssl) { parsedValues.useSSL = normalizeBool(ssl); if (!parsedValues.useSSL) { parsedValues.sslMode = 'disable'; } else { parsedValues.sslMode = normalizeBool(sslVerify || 'true') ? 'required' : 'skip-verify'; } } } else if (type === 'tdengine') { const protocol = String(parsed.params.get('protocol') || '').trim().toLowerCase(); const skipVerify = normalizeBool(parsed.params.get('skip_verify')); if (protocol === 'wss') { parsedValues.useSSL = true; parsedValues.sslMode = skipVerify ? 'skip-verify' : 'required'; } else if (protocol === 'ws') { parsedValues.useSSL = false; parsedValues.sslMode = 'disable'; } } }; return parsedValues; } return null; }; const createUriAwareRequiredRule = ( messageText: string, validateValue?: (value: unknown) => boolean ) => ({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({ validator(_: unknown, value: unknown) { const uriText = String(getFieldValue('uri') || '').trim(); const type = String(getFieldValue('type') || dbType).trim().toLowerCase(); if (uriText && parseUriToValues(uriText, type)) { return Promise.resolve(); } const valid = validateValue ? validateValue(value) : String(value ?? '').trim() !== ''; return valid ? Promise.resolve() : Promise.reject(new Error(messageText)); } }); const getUriPlaceholder = () => { if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') { const defaultPort = getDefaultPortByType(dbType); const scheme = dbType === 'diros' ? 'doris' : 'mysql'; return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; } if (isFileDatabaseType(dbType)) { return dbType === 'duckdb' ? 'duckdb:///Users/name/demo.duckdb' : 'sqlite:///Users/name/demo.sqlite'; } if (dbType === 'mongodb') { return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256'; } if (dbType === 'clickhouse') { return 'clickhouse://default:pass@127.0.0.1:9000/default'; } if (dbType === 'redis') { return 'redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster'; } if (dbType === 'oracle') { return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1'; } return '例如: postgres://user:pass@127.0.0.1:5432/db_name'; }; const buildUriFromValues = (values: any) => { const type = String(values.type || '').trim().toLowerCase(); const defaultPort = getDefaultPortByType(type); const host = String(values.host || 'localhost').trim(); const port = Number(values.port || defaultPort); const user = String(values.user || '').trim(); const password = String(values.password || ''); const database = String(values.database || '').trim(); const timeout = Number(values.timeout || 30); const encodedAuth = user ? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@` : ''; if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const primary = toAddress(host, port, defaultPort); const replicas = values.mysqlTopology === 'replica' ? normalizeAddressList(values.mysqlReplicaHosts, defaultPort) : []; const hosts = normalizeAddressList([primary, ...replicas], defaultPort); const params = new URLSearchParams(); if (hosts.length > 1 || values.mysqlTopology === 'replica') { params.set('topology', 'replica'); } if (values.useSSL) { const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); if (mode === 'required') { params.set('tls', 'true'); } else if (mode === 'skip-verify') { params.set('tls', 'skip-verify'); } else { params.set('tls', 'preferred'); } } if (Number.isFinite(timeout) && timeout > 0) { params.set('timeout', String(timeout)); } const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; const query = params.toString(); const scheme = type === 'diros' ? 'doris' : 'mysql'; return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } if (type === 'redis') { const primary = toAddress(host, port, 6379); const clusterHosts = values.redisTopology === 'cluster' ? normalizeAddressList(values.redisHosts, 6379) : []; const hosts = normalizeAddressList([primary, ...clusterHosts], 6379); const params = new URLSearchParams(); if (hosts.length > 1 || values.redisTopology === 'cluster') { params.set('topology', 'cluster'); } const redisPassword = String(values.password || ''); const redisAuth = redisPassword ? `:${encodeURIComponent(redisPassword)}@` : ''; const redisDB = Number.isFinite(Number(values.redisDB)) ? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB)))) : 0; const dbPath = `/${redisDB}`; if (values.useSSL) { const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); if (mode === 'skip-verify' || mode === 'preferred') { params.set('skip_verify', 'true'); } } const query = params.toString(); const scheme = values.useSSL ? 'rediss' : 'redis'; return `${scheme}://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } if (isFileDatabaseType(type)) { const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } return `${type}://${encodeURI(pathText)}`; } if (type === 'mongodb') { const useSrv = !!values.mongoSrv; const primaryAddress = useSrv ? (parseHostPort(host, 27017)?.host || host || 'localhost') : toAddress(host, port, 27017); const extraNodes = values.mongoTopology === 'replica' ? (useSrv ? normalizeMongoSrvHostList(values.mongoHosts, 27017) : normalizeAddressList(values.mongoHosts, 27017)) : []; const hosts = useSrv ? normalizeMongoSrvHostList([primaryAddress, ...extraNodes], 27017) : normalizeAddressList([primaryAddress, ...extraNodes], 27017); const scheme = useSrv ? 'mongodb+srv' : 'mongodb'; const params = new URLSearchParams(); const authSource = String(values.mongoAuthSource || database || 'admin').trim(); if (authSource) { params.set('authSource', authSource); } const replicaSet = String(values.mongoReplicaSet || '').trim(); if (replicaSet) { params.set('replicaSet', replicaSet); } const readPreference = String(values.mongoReadPreference || '').trim(); if (readPreference) { params.set('readPreference', readPreference); } const authMechanism = String(values.mongoAuthMechanism || '').trim(); if (authMechanism) { params.set('authMechanism', authMechanism); } if (values.useSSL) { const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); params.set('tls', 'true'); if (mode === 'skip-verify' || mode === 'preferred') { params.set('tlsInsecure', 'true'); } else { params.delete('tlsInsecure'); } } if (Number.isFinite(timeout) && timeout > 0) { params.set('connectTimeoutMS', String(timeout * 1000)); params.set('serverSelectionTimeoutMS', String(timeout * 1000)); } const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; const query = params.toString(); return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } const scheme = type === 'postgres' ? 'postgresql' : type; const dbPath = database ? `/${encodeURIComponent(database)}` : ''; const params = new URLSearchParams(); if (supportsSSLForType(type) && values.useSSL) { const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { params.set('sslmode', 'require'); } else if (type === 'sqlserver') { params.set('encrypt', 'true'); params.set('TrustServerCertificate', mode === 'skip-verify' || mode === 'preferred' ? 'true' : 'false'); } else if (type === 'clickhouse') { params.set('secure', 'true'); if (mode === 'skip-verify' || mode === 'preferred') { params.set('skip_verify', 'true'); } } else if (type === 'dameng') { const certPath = String(values.sslCertPath || '').trim(); const keyPath = String(values.sslKeyPath || '').trim(); if (certPath) params.set('SSL_CERT_PATH', certPath); if (keyPath) params.set('SSL_KEY_PATH', keyPath); } else if (type === 'oracle') { params.set('SSL', 'TRUE'); params.set('SSL VERIFY', mode === 'required' ? 'TRUE' : 'FALSE'); } else if (type === 'tdengine') { params.set('protocol', 'wss'); if (mode === 'skip-verify' || mode === 'preferred') { params.set('skip_verify', 'true'); } } } else if (supportsSSLForType(type)) { if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { params.set('sslmode', 'disable'); } else if (type === 'sqlserver') { params.set('encrypt', 'disable'); params.set('TrustServerCertificate', 'true'); } else if (type === 'tdengine') { params.set('protocol', 'ws'); } } const query = params.toString(); return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ''}`; }; const handleGenerateURI = () => { try { const values = form.getFieldsValue(true); const uri = buildUriFromValues(values); form.setFieldValue('uri', uri); setUriFeedback({ type: 'success', message: 'URI 已生成' }); } catch { setUriFeedback({ type: 'error', message: '生成 URI 失败' }); } }; const handleParseURI = () => { try { const uriText = String(form.getFieldValue('uri') || '').trim(); const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase(); if (!uriText) { setUriFeedback({ type: 'warning', message: '请先输入 URI' }); return; } const parsedValues = parseUriToValues(uriText, type); if (!parsedValues) { setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' }); return; } form.setFieldsValue({ ...parsedValues, uri: uriText }); if (testResult) { setTestResult(null); } setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' }); } catch { setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' }); } }; const handleCopyURI = async () => { let uriText = String(form.getFieldValue('uri') || '').trim(); if (!uriText) { const values = form.getFieldsValue(true); uriText = buildUriFromValues(values); form.setFieldValue('uri', uriText); } if (!uriText) { setUriFeedback({ type: 'warning', message: '没有可复制的 URI' }); return; } try { await navigator.clipboard.writeText(uriText); setUriFeedback({ type: 'success', message: 'URI 已复制' }); } catch { setUriFeedback({ type: 'error', message: '复制失败' }); } }; const handleSelectSSHKeyFile = async () => { if (selectingSSHKey) { return; } try { setSelectingSSHKey(true); const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim(); const res = await SelectSSHKeyFile(currentPath); if (res?.success) { const data = res.data || {}; const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); if (selectedPath) { form.setFieldValue('sshKeyPath', selectedPath); } } else if (res?.message !== 'Cancelled') { message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`); } } catch (e: any) { message.error(`选择私钥文件失败: ${e?.message || String(e)}`); } finally { setSelectingSSHKey(false); } }; const handleSelectDatabaseFile = async () => { if (selectingDbFile) { return; } try { setSelectingDbFile(true); const currentPath = String(form.getFieldValue('host') || '').trim(); const res = await SelectDatabaseFile(currentPath, dbType); if (res?.success) { const data = res.data || {}; const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); if (selectedPath) { form.setFieldValue('host', normalizeFileDbPath(selectedPath)); } } else if (res?.message !== 'Cancelled') { message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`); } } catch (e: any) { message.error(`选择数据库文件失败: ${e?.message || String(e)}`); } finally { setSelectingDbFile(false); } }; useEffect(() => { if (open) { setTestResult(null); // Reset test result setTestErrorLogOpen(false); setDbList([]); setRedisDbList([]); setMongoMembers([]); setUriFeedback(null); setTypeSelectWarning(null); setDriverStatusLoaded(false); void refreshDriverStatus(); if (initialValues) { // Edit mode: Go directly to step 2 setStep(2); const config: any = initialValues.config || {}; const configType = String(config.type || 'mysql'); const defaultPort = getDefaultPortByType(configType); const isFileDbConfigType = isFileDatabaseType(configType); const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort); const primaryAddress = isFileDbConfigType ? null : parseHostPort( normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), defaultPort ); const primaryHost = isFileDbConfigType ? normalizeFileDbPath(String(config.host || '')) : (primaryAddress?.host || String(config.host || 'localhost')); const primaryPort = isFileDbConfigType ? 0 : (primaryAddress?.port || Number(config.port || defaultPort)); const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; const redisHosts = configType === 'redis' ? normalizedHosts.slice(1) : []; const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet; const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0; const hasHttpTunnel = !!config.useHttpTunnel; const hasProxy = !hasHttpTunnel && !!config.useProxy; form.setFieldsValue({ type: configType, name: initialValues.name, host: primaryHost, port: primaryPort, user: config.user, password: config.password, database: config.database, uri: config.uri || '', includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, useSSL: !!config.useSSL, sslMode: config.sslMode || 'preferred', sslCertPath: config.sslCertPath || '', sslKeyPath: config.sslKeyPath || '', useSSH: config.useSSH, sshHost: config.ssh?.host, sshPort: config.ssh?.port, sshUser: config.ssh?.user, sshPassword: config.ssh?.password, sshKeyPath: config.ssh?.keyPath, useProxy: hasProxy, proxyType: config.proxy?.type || 'socks5', proxyHost: config.proxy?.host, proxyPort: config.proxy?.port, proxyUser: config.proxy?.user, proxyPassword: config.proxy?.password, useHttpTunnel: hasHttpTunnel, httpTunnelHost: config.httpTunnel?.host, httpTunnelPort: config.httpTunnel?.port || 8080, httpTunnelUser: config.httpTunnel?.user, httpTunnelPassword: config.httpTunnel?.password, driver: config.driver, dsn: config.dsn, timeout: config.timeout || 30, mysqlTopology: mysqlIsReplica ? 'replica' : 'single', mysqlReplicaHosts: mysqlReplicaHosts, mysqlReplicaUser: config.mysqlReplicaUser || '', mysqlReplicaPassword: config.mysqlReplicaPassword || '', mongoTopology: mongoIsReplica ? 'replica' : 'single', mongoHosts: mongoHosts, redisTopology: redisIsCluster ? 'cluster' : 'single', redisHosts: redisHosts, mongoSrv: !!config.mongoSrv, mongoReplicaSet: config.replicaSet || '', mongoAuthSource: config.authSource || '', mongoReadPreference: config.readPreference || 'primary', mongoAuthMechanism: config.mongoAuthMechanism || '', savePassword: config.savePassword !== false, redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0, mongoReplicaUser: config.mongoReplicaUser || '', mongoReplicaPassword: config.mongoReplicaPassword || '' }); setUseSSL(!!config.useSSL); setUseSSH(config.useSSH || false); setUseProxy(hasProxy); setUseHttpTunnel(hasHttpTunnel); setDbType(configType); if (config.useSSL && supportsSSLForType(configType)) { setActiveNetworkConfig('ssl'); } else if (config.useSSH) { setActiveNetworkConfig('ssh'); } else if (hasProxy) { setActiveNetworkConfig('proxy'); } else if (hasHttpTunnel) { setActiveNetworkConfig('httpTunnel'); } else { setActiveNetworkConfig('ssl'); } // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 if (configType === 'redis') { setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); } } else { // Create mode: Start at step 1 setActiveConfigSection('basic'); setStep(1); form.resetFields(); setUseSSL(false); setUseSSH(false); setUseProxy(false); setUseHttpTunnel(false); setDbType('mysql'); setActiveGroup(0); setActiveConfigSection('basic'); setActiveNetworkConfig('ssl'); } } }, [open, initialValues]); useEffect(() => { return () => { if (testTimerRef.current !== null) { window.clearTimeout(testTimerRef.current); testTimerRef.current = null; } }; }, []); const handleOk = async () => { try { const values = await form.validateFields(); const unavailableReason = await resolveDriverUnavailableReason(values.type); if (unavailableReason) { message.warning(unavailableReason); promptInstallDriver(values.type, unavailableReason); return; } setLoading(true); const config = await buildConfig(values, true); const displayHost = String((config as any).host || values.host || '').trim(); const isRedisType = values.type === 'redis'; const newConn = { id: initialValues ? initialValues.id : Date.now().toString(), name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), config: config, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined }; if (initialValues) { updateConnection(newConn); message.success('配置已更新(未连接)'); } else { addConnection(newConn); message.success('配置已保存(未连接)'); } setLoading(false); form.resetFields(); setUseSSL(false); setUseSSH(false); setUseProxy(false); setUseHttpTunnel(false); setDbType('mysql'); setStep(1); onClose(); } catch (e) { setLoading(false); } }; const requestTest = () => { if (loading) return; if (testTimerRef.current !== null) return; testTimerRef.current = window.setTimeout(() => { testTimerRef.current = null; handleTest(); }, 0); }; const buildTestFailureMessage = (reason: unknown, fallback: string) => { const text = String(reason ?? '').trim(); const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback; return `测试失败: ${normalized}`; }; const handleTest = async () => { if (testInFlightRef.current) return; testInFlightRef.current = true; try { const values = await form.validateFields(); const unavailableReason = await resolveDriverUnavailableReason(values.type); if (unavailableReason) { const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用'); setTestResult({ type: 'error', message: failMessage }); promptInstallDriver(values.type, unavailableReason); return; } setLoading(true); setTestResult(null); const config = await buildConfig(values, false); // Use different API for Redis const isRedisType = values.type === 'redis'; const res = isRedisType ? await RedisConnect(config as any) : await TestConnection(config as any); if (res.success) { setTestResult({ type: 'success', message: res.message }); if (isRedisType) { setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); } else { // Other databases: fetch database list const dbRes = await DBGetDatabases(config as any); if (dbRes.success) { const dbRows = Array.isArray(dbRes.data) ? dbRes.data : []; const dbs = dbRows .map((row: any) => row?.Database || row?.database) .filter((name: any) => typeof name === 'string' && name.trim() !== ''); setDbList(dbs); if (dbs.length === 0) { message.warning(values.type === 'dameng' ? '连接成功,但未获取到可见 schema;请检查当前账号权限或默认 schema 配置' : '连接成功,但未获取到可见数据库列表'); } } else { setDbList([]); message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`); } } } else { const failMessage = buildTestFailureMessage( res?.message, '连接被拒绝或参数无效,请检查后重试' ); setTestResult({ type: 'error', message: failMessage }); } } catch (e: unknown) { if (e && typeof e === 'object' && 'errorFields' in e) { const failMessage = '测试失败: 请先完善必填项后再测试连接'; setTestResult({ type: 'error', message: failMessage }); return; } const reason = e instanceof Error ? e.message : (typeof e === 'string' ? e : '未知异常'); const failMessage = buildTestFailureMessage(reason, '未知异常'); setTestResult({ type: 'error', message: failMessage }); } finally { testInFlightRef.current = false; setLoading(false); } }; const handleDiscoverMongoMembers = async () => { if (discoveringMembers || dbType !== 'mongodb') { return; } try { const values = await form.validateFields(); setDiscoveringMembers(true); const config = await buildConfig(values, false); const result = await MongoDiscoverMembers(config as any); if (!result.success) { message.error(result.message || '成员发现失败'); return; } const data = (result.data as Record) || {}; const membersRaw = Array.isArray(data.members) ? data.members : []; const members: MongoMemberInfo[] = membersRaw .map((item: any) => ({ host: String(item.host || '').trim(), role: String(item.role || item.state || 'UNKNOWN').trim(), state: String(item.state || item.role || 'UNKNOWN').trim(), stateCode: Number(item.stateCode || 0), healthy: !!item.healthy, isSelf: !!item.isSelf, })) .filter((item: MongoMemberInfo) => !!item.host); setMongoMembers(members); if (!form.getFieldValue('mongoReplicaSet') && data.replicaSet) { form.setFieldValue('mongoReplicaSet', String(data.replicaSet)); } message.success(result.message || `发现 ${members.length} 个成员`); } catch (error: any) { message.error(error?.message || '成员发现失败'); } finally { setDiscoveringMembers(false); } }; const buildConfig = async (values: any, forPersist: boolean): Promise => { const mergedValues = { ...values }; const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type); const isEmptyField = (value: unknown) => ( value === undefined || value === null || value === '' || value === 0 || (Array.isArray(value) && value.length === 0) ); if (parsedUriValues) { Object.entries(parsedUriValues).forEach(([key, value]) => { if (isEmptyField((mergedValues as any)[key])) { (mergedValues as any)[key] = value; } }); } const type = String(mergedValues.type || '').toLowerCase(); const defaultPort = getDefaultPortByType(type); const isFileDbType = isFileDatabaseType(type); const sslCapableType = supportsSSLForType(type); const sslModeRaw = String(mergedValues.sslMode || 'preferred').trim().toLowerCase(); const sslMode: 'preferred' | 'required' | 'skip-verify' | 'disable' = sslModeRaw === 'required' ? 'required' : sslModeRaw === 'skip-verify' ? 'skip-verify' : sslModeRaw === 'disable' ? 'disable' : 'preferred'; const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL; const sslCertPath = sslCapableType ? String(mergedValues.sslCertPath || '').trim() : ''; const sslKeyPath = sslCapableType ? String(mergedValues.sslKeyPath || '').trim() : ''; if (type === 'dameng' && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) { throw new Error('达梦启用 SSL 时必须填写证书路径与私钥路径'); } let primaryHost = 'localhost'; let primaryPort = defaultPort; if (isFileDbType) { // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim()); primaryPort = 0; } else { const parsedPrimary = parseHostPort( toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), defaultPort ); primaryHost = parsedPrimary?.host || 'localhost'; primaryPort = parsedPrimary?.port || defaultPort; } let hosts: string[] = []; let topology: 'single' | 'replica' | 'cluster' | undefined; let replicaSet = ''; let authSource = ''; let readPreference = ''; let mysqlReplicaUser = ''; let mysqlReplicaPassword = ''; let mongoSrvEnabled = false; let mongoAuthMechanism = ''; let mongoReplicaUser = ''; let mongoReplicaPassword = ''; const savePassword = type === 'mongodb' ? mergedValues.savePassword !== false : true; if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const replicas = mergedValues.mysqlTopology === 'replica' ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) : []; const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...replicas], defaultPort); if (mergedValues.mysqlTopology === 'replica' || allHosts.length > 1) { hosts = allHosts; topology = 'replica'; mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || '').trim(); mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || ''); } else { topology = 'single'; } } if (type === 'mongodb') { mongoSrvEnabled = !!mergedValues.mongoSrv; const extraHosts = mergedValues.mongoTopology === 'replica' ? (mongoSrvEnabled ? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort) : normalizeAddressList(mergedValues.mongoHosts, defaultPort)) : []; const primarySeed = mongoSrvEnabled ? primaryHost : `${primaryHost}:${primaryPort}`; const allHosts = mongoSrvEnabled ? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort) : normalizeAddressList([primarySeed, ...extraHosts], defaultPort); if (mergedValues.mongoTopology === 'replica' || allHosts.length > 1 || mergedValues.mongoReplicaSet) { hosts = allHosts; topology = 'replica'; mongoReplicaUser = String(mergedValues.mongoReplicaUser || '').trim(); mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || ''); } else { topology = 'single'; } replicaSet = String(mergedValues.mongoReplicaSet || '').trim(); authSource = String(mergedValues.mongoAuthSource || mergedValues.database || 'admin').trim(); readPreference = String(mergedValues.mongoReadPreference || 'primary').trim(); mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase(); } if (type === 'redis') { const clusterNodes = mergedValues.redisTopology === 'cluster' ? normalizeAddressList(mergedValues.redisHosts, defaultPort) : []; const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...clusterNodes], defaultPort); if (mergedValues.redisTopology === 'cluster' || allHosts.length > 1) { hosts = allHosts; topology = 'cluster'; } else { topology = 'single'; } mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB)) ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) : 0; } const sshConfig = mergedValues.useSSH ? { host: mergedValues.sshHost, port: Number(mergedValues.sshPort), user: mergedValues.sshUser, password: mergedValues.sshPassword || "", keyPath: mergedValues.sshKeyPath || "" } : { host: "", port: 22, user: "", password: "", keyPath: "" }; const effectiveUseHttpTunnel = !isFileDbType && !!mergedValues.useHttpTunnel; const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel; const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase(); const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5'; const proxyConfig: NonNullable = effectiveUseProxy ? { type: proxyType, host: String(mergedValues.proxyHost || '').trim(), port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)), user: String(mergedValues.proxyUser || '').trim(), password: mergedValues.proxyPassword || "", } : { type: 'socks5', host: '', port: 1080, user: '', password: '', }; const httpTunnelConfig: NonNullable = effectiveUseHttpTunnel ? { host: String(mergedValues.httpTunnelHost || '').trim(), port: Number(mergedValues.httpTunnelPort || 8080), user: String(mergedValues.httpTunnelUser || '').trim(), password: mergedValues.httpTunnelPassword || "", } : { host: '', port: 8080, user: '', password: '', }; if (effectiveUseHttpTunnel) { if (!httpTunnelConfig.host) { throw new Error('HTTP 隧道主机不能为空'); } if (!Number.isFinite(httpTunnelConfig.port) || httpTunnelConfig.port <= 0 || httpTunnelConfig.port > 65535) { throw new Error('HTTP 隧道端口必须在 1-65535 之间'); } } const keepPassword = !forPersist || savePassword; return { type: mergedValues.type, host: primaryHost, port: Number(primaryPort || 0), user: mergedValues.user || "", password: keepPassword ? (mergedValues.password || "") : "", savePassword: savePassword, database: mergedValues.database || "", useSSL: effectiveUseSSL, sslMode: effectiveUseSSL ? sslMode : 'disable', sslCertPath: sslCertPath, sslKeyPath: sslKeyPath, useSSH: !!mergedValues.useSSH, ssh: sshConfig, useProxy: effectiveUseProxy, proxy: proxyConfig, useHttpTunnel: effectiveUseHttpTunnel, httpTunnel: httpTunnelConfig, driver: mergedValues.driver, dsn: mergedValues.dsn, timeout: Number(mergedValues.timeout || 30), redisDB: Number.isFinite(Number(mergedValues.redisDB)) ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) : 0, uri: String(mergedValues.uri || '').trim(), hosts: hosts, topology: topology, mysqlReplicaUser: mysqlReplicaUser, mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "", replicaSet: replicaSet, authSource: authSource, readPreference: readPreference, mongoSrv: mongoSrvEnabled, mongoAuthMechanism: mongoAuthMechanism, mongoReplicaUser: mongoReplicaUser, mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", }; }; const handleTypeSelect = async (type: string) => { const unavailableReason = await resolveDriverUnavailableReason(type); if (unavailableReason) { const normalized = normalizeDriverType(type); const driverName = driverStatusMap[normalized]?.name || type; setTypeSelectWarning({ driverName, reason: unavailableReason }); return; } setTypeSelectWarning(null); setDbType(type); form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); if (isFileDatabaseType(type)) { setUseSSL(false); setUseSSH(false); setUseProxy(false); setUseHttpTunnel(false); form.setFieldsValue({ host: '', port: 0, user: '', password: '', database: '', useSSL: false, sslMode: 'preferred', sslCertPath: '', sslKeyPath: '', useSSH: false, sshHost: '', sshPort: 22, sshUser: '', sshPassword: '', sshKeyPath: '', useProxy: false, proxyType: 'socks5', proxyHost: '', proxyPort: 1080, proxyUser: '', proxyPassword: '', useHttpTunnel: false, httpTunnelHost: '', httpTunnelPort: 8080, httpTunnelUser: '', httpTunnelPassword: '', mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', mongoSrv: false, mongoReadPreference: 'primary', mongoReplicaSet: '', mongoAuthSource: '', mongoAuthMechanism: '', savePassword: true, mysqlReplicaHosts: [], redisHosts: [], mongoHosts: [], mysqlReplicaUser: '', mysqlReplicaPassword: '', mongoReplicaUser: '', mongoReplicaPassword: '', redisDB: 0, }); } else if (type !== 'custom') { const defaultUser = type === 'clickhouse' ? 'default' : 'root'; const sslCapableType = supportsSSLForType(type); setUseSSL(false); setUseHttpTunnel(false); form.setFieldsValue({ user: defaultUser, database: '', port: defaultPort, useSSL: sslCapableType ? false : undefined, sslMode: sslCapableType ? 'preferred' : undefined, sslCertPath: sslCapableType ? '' : undefined, sslKeyPath: sslCapableType ? '' : undefined, useHttpTunnel: false, httpTunnelHost: '', httpTunnelPort: 8080, httpTunnelUser: '', httpTunnelPassword: '', mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', mongoSrv: false, mongoReadPreference: 'primary', mongoReplicaSet: '', mongoAuthSource: '', mongoAuthMechanism: '', savePassword: true, mysqlReplicaHosts: [], redisHosts: [], mongoHosts: [], mysqlReplicaUser: '', mysqlReplicaPassword: '', mongoReplicaUser: '', mongoReplicaPassword: '', redisDB: 0, }); } setMongoMembers([]); setStep(2); }; const isFileDb = isFileDatabaseType(dbType); const isCustom = dbType === 'custom'; const isRedis = dbType === 'redis'; const currentDriverType = normalizeDriverType(dbType); const currentDriverSnapshot = driverStatusMap[currentDriverType]; const currentDriverUnavailableReason = currentDriverType !== 'custom' && currentDriverSnapshot && !currentDriverSnapshot.connectable ? (currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用`) : ''; const driverStatusChecking = currentDriverType !== 'custom' && !driverStatusLoaded && step === 2; const dbTypeGroups = [ { label: '关系型数据库', items: [ { key: 'mysql', name: 'MySQL', icon: }, { key: 'mariadb', name: 'MariaDB', icon: }, { key: 'diros', name: 'Doris', icon: }, { key: 'sphinx', name: 'Sphinx', icon: }, { key: 'clickhouse', name: 'ClickHouse', icon: }, { key: 'postgres', name: 'PostgreSQL', icon: }, { key: 'sqlserver', name: 'SQL Server', icon: }, { key: 'sqlite', name: 'SQLite', icon: }, { key: 'duckdb', name: 'DuckDB', icon: }, { key: 'oracle', name: 'Oracle', icon: }, ]}, { label: '国产数据库', items: [ { key: 'dameng', name: 'Dameng (达梦)', icon: }, { key: 'kingbase', name: 'Kingbase (人大金仓)', icon: }, { key: 'highgo', name: 'HighGo (瀚高)', icon: }, { key: 'vastbase', name: 'Vastbase (海量)', icon: }, ]}, { label: 'NoSQL', items: [ { key: 'mongodb', name: 'MongoDB', icon: }, { key: 'redis', name: 'Redis', icon: }, ]}, { label: '时序数据库', items: [ { key: 'tdengine', name: 'TDengine', icon: }, ]}, { label: '其他', items: [ { key: 'custom', name: 'Custom (自定义)', icon: }, ]}, ]; const dbTypes = dbTypeGroups.flatMap(g => g.items); const renderStep1 = () => (
选择数据源
先选择目标数据库或中间件类型,再进入详细连接参数配置。
{typeSelectWarning && ( {typeSelectWarning.reason} )} onClose={() => setTypeSelectWarning(null)} /> )}
{/* 左侧分类导航 */}
{dbTypeGroups.map((group, idx) => (
setActiveGroup(idx)} style={{ padding: '10px 12px', cursor: 'pointer', borderRadius: 6, marginBottom: 4, background: activeGroup === idx ? step1SidebarActiveBg : 'transparent', color: activeGroup === idx ? step1SidebarActiveColor : undefined, fontWeight: activeGroup === idx ? 500 : 400, transition: 'all 0.2s', fontSize: 13, }} > {group.label}
))}
{/* 右侧数据源卡片 */}
{dbTypeGroups[activeGroup]?.items.map(item => ( { void handleTypeSelect(item.key); }} style={{ textAlign: 'center', cursor: 'pointer', height: 100 }} styles={{ body: { padding: '16px 8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' } }} >
{item.icon}
{item.name}
))}
); const renderStep2 = () => { const baseInfoSection = (
基础信息
常用参数集中在左侧,优先完成连接建立所需的最小输入。
{!isCustom && ( <> {uriFeedback && ( setUriFeedback(null)} style={{ marginBottom: 16 }} /> )} )} {isCustom ? ( <> ) : ( <>
{isFileDb ? ( ) : ( Number(value) > 0)]} style={{ marginBottom: 0 }} > )}
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( )} {dbType === 'oracle' && ( )} {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( <>
)} )} {dbType === 'mongodb' && ( <>
{mongoMembers.length > 0 && ( record.host} pagination={false} dataSource={mongoMembers} style={{ marginBottom: 12 }} columns={[ { title: 'Host', dataIndex: 'host', width: '48%' }, { title: '角色', dataIndex: 'role', width: '32%', render: (value: string, record: MongoMemberInfo) => ( {value || 'UNKNOWN'} ), }, { title: '健康', dataIndex: 'healthy', width: '20%', render: (value: boolean) => ( {value ? '正常' : '异常'} ), }, ]} /> )} )}
{redisTopology === 'cluster' && ( {redisDbList.map(db => db{db})} )} {!isFileDb && !isRedis && (
{dbType === 'mongodb' && ( {dbList.map(db => {db})} )} )}
); const networkSecuritySection = !isFileDb ? (() => { const networkItems: Array<{ key: 'ssl' | 'ssh' | 'proxy' | 'httpTunnel'; title: string; description: string; enabled: boolean; }> = [ ...(isSSLType ? [{ key: 'ssl' as const, title: 'SSL/TLS', description: '加密与证书校验', enabled: useSSL }] : []), { key: 'ssh', title: 'SSH 隧道', description: '跳板机 / 堡垒机转发', enabled: useSSH }, { key: 'proxy', title: '代理', description: 'SOCKS5 / HTTP CONNECT', enabled: useProxy }, { key: 'httpTunnel', title: 'HTTP 隧道', description: '独立 HTTP CONNECT 路由', enabled: useHttpTunnel }, ]; const resolvedNetworkConfig = networkItems.some((item) => item.key === activeNetworkConfig) ? activeNetworkConfig : networkItems[0]?.key || 'ssh'; const renderNetworkPanel = () => { if (resolvedNetworkConfig === 'ssl') { return (
SSL/TLS
为连接链路增加加密与证书校验控制,适合生产或跨网络访问场景。
{!useSSL ? (
左侧勾选“SSL/TLS”后,可在这里配置模式、证书与校验策略。
) : (
)} {sslHintText}
)}
); } if (resolvedNetworkConfig === 'ssh') { return (
SSH 隧道
通过跳板机或堡垒机转发数据库连接,适合内网或受限网络环境。
{!useSSH ? (
左侧勾选“SSH 隧道”后,可在这里填写主机、端口、用户名、密码和私钥路径。
) : (
)}
); } if (resolvedNetworkConfig === 'proxy') { return (
代理
适合借助本地代理软件或中间网关转发数据库流量。
{!useProxy ? (
左侧勾选“代理”后,可在这里选择代理类型并填写主机、端口与认证信息。
) : (
)}
); } return (
HTTP 隧道
与代理模式互斥,适合单独指定一条 HTTP CONNECT 隧道路由。
{!useHttpTunnel ? (
左侧勾选“HTTP 隧道”后,可在这里填写隧道目标与认证信息。
) : (
与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。
)}
); }; return (
网络与安全
上方稳定列出所有连接方式,下方固定展示当前方式的配置详情,避免启用后页面重新排布,同时给详情区留出足够宽度。
{networkItems.map((item) => { const active = item.key === resolvedNetworkConfig; const activeColor = darkMode ? '#ffd666' : '#1677ff'; return (
setActiveNetworkConfig(item.key)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setActiveNetworkConfig(item.key); } }} style={{ ...getConnectionOptionCardStyle(item.enabled), borderColor: active ? (darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.36)') : 'transparent', background: active ? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)') : getConnectionOptionCardStyle(item.enabled).background, boxShadow: active ? (darkMode ? '0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)' : '0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)') : 'none', cursor: 'pointer', outline: 'none', }} >
{item.title}
{active && ( 当前编辑 )} {item.enabled ? '已启用' : '未启用'}
{item.description}
); })}
{renderNetworkPanel()}
高级连接
); })() : null; return (
{ if (testResult) { setTestResult(null); setTestErrorLogOpen(false); } if (changed.uri !== undefined || changed.type !== undefined) { setUriFeedback(null); } if (changed.useSSL !== undefined) { setUseSSL(changed.useSSL); if (changed.useSSL) setActiveNetworkConfig('ssl'); } if (changed.useSSH !== undefined) { setUseSSH(changed.useSSH); if (changed.useSSH) setActiveNetworkConfig('ssh'); } if (changed.useProxy !== undefined) { const enabledProxy = !!changed.useProxy; setUseProxy(enabledProxy); if (enabledProxy) setActiveNetworkConfig('proxy'); if (enabledProxy && form.getFieldValue('useHttpTunnel')) { form.setFieldValue('useHttpTunnel', false); setUseHttpTunnel(false); } } if (changed.proxyType !== undefined) { const nextType = String(changed.proxyType || 'socks5').toLowerCase(); if (nextType === 'http') { const currentPort = Number(form.getFieldValue('proxyPort') || 0); if (!currentPort || currentPort === 1080) { form.setFieldValue('proxyPort', 8080); } } else { const currentPort = Number(form.getFieldValue('proxyPort') || 0); if (!currentPort || currentPort === 8080) { form.setFieldValue('proxyPort', 1080); } } } if (changed.useHttpTunnel !== undefined) { const enabledHttpTunnel = !!changed.useHttpTunnel; setUseHttpTunnel(enabledHttpTunnel); if (enabledHttpTunnel) setActiveNetworkConfig('httpTunnel'); if (enabledHttpTunnel && form.getFieldValue('useProxy')) { form.setFieldValue('useProxy', false); setUseProxy(false); } if (enabledHttpTunnel) { const currentPort = Number(form.getFieldValue('httpTunnelPort') || 0); if (!currentPort || currentPort <= 0) { form.setFieldValue('httpTunnelPort', 8080); } } } if (changed.type !== undefined) setDbType(changed.type); if (changed.redisTopology !== undefined) { const supportedDbs = Array.from({ length: 16 }, (_, i) => i); setRedisDbList(supportedDbs); const selectedDbsRaw = form.getFieldValue('includeRedisDatabases'); const selectedDbs = Array.isArray(selectedDbsRaw) ? selectedDbsRaw.map((entry: any) => Number(entry)) : []; const validDbs = selectedDbs .filter((entry: number) => Number.isFinite(entry)) .map((entry: number) => Math.trunc(entry)) .filter((entry: number) => supportedDbs.includes(entry)); form.setFieldValue('includeRedisDatabases', validDbs.length > 0 ? validDbs : undefined); } if ( changed.type !== undefined || changed.host !== undefined || changed.port !== undefined || changed.mongoHosts !== undefined || changed.mongoTopology !== undefined || changed.mongoSrv !== undefined ) { setMongoMembers([]); } }} > {currentDriverUnavailableReason && ( {currentDriverUnavailableReason} )} /> )} {(() => { const sectionItems: Array<{ key: 'basic' | 'network'; title: string; description: string; icon: React.ReactNode }> = [ { key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: }, ...(!isCustom && !isFileDb ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: }] : []), ]; const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection) ? activeConfigSection : sectionItems[0]?.key || 'basic'; const currentSectionContent = resolvedSection === 'basic' ? baseInfoSection : networkSecuritySection; if (sectionItems.length <= 1) { return currentSectionContent; } return (
配置分区
{sectionItems.map((item) => { const active = item.key === resolvedSection; return ( ); })}
{currentSectionContent}
); })()} ); }; const getFooter = () => { if (step === 1) { return [ ]; } const isTestSuccess = testResult?.type === 'success'; const hasTestError = !!testResult && !isTestSuccess; const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking; return (
{!initialValues && } {testResult ? ( {isTestSuccess ? : } {isTestSuccess ? '连接成功' : '连接失败'} ) : null} {hasTestError && ( )}
); }; const getTitle = () => { if (step === 1) { return renderConnectionModalTitle(, '选择数据源类型', '按数据库、中间件或文件类型快速进入对应的连接配置流程。'); } const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType; return initialValues ? renderConnectionModalTitle(, '编辑连接', `调整 ${typeName} 连接的参数、认证方式与网络选项。`) : renderConnectionModalTitle(, `新建 ${typeName} 连接`, '填写连接参数、测试连通性,并保存到连接树中。'); }; const modalBodyStyle = { padding: '12px 24px 18px', height: CONNECTION_MODAL_BODY_HEIGHT, overflowY: 'auto' as const, overflowX: 'hidden' as const, }; return ( <> {step === 1 ? renderStep1() : renderStep2()} , '测试连接失败原因', '查看本次测试连接的完整错误上下文,便于快速定位配置问题。')} open={testErrorLogOpen} onCancel={() => setTestErrorLogOpen(false)} centered width={760} zIndex={10002} destroyOnHidden styles={{ content: modalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} footer={[ , ]} >
              {String(testResult?.message || '暂无失败日志')}
          
); }; export default ConnectionModal;