import React, { useState, useEffect, useRef, useMemo } from "react"; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Select, Alert, Card, Row, Col, Typography, Space, Table, Tag, Switch, } from "antd"; import { DatabaseOutlined, FileTextOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined, ApiOutlined, ClusterOutlined, CodeOutlined, GatewayOutlined, SafetyCertificateOutlined, ThunderboltOutlined, } from "@ant-design/icons"; import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS, } from "./DatabaseIcons"; import { useStore } from "../store"; import { buildOverlayWorkbenchTheme } from "../utils/overlayWorkbenchTheme"; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, } from "../utils/appearance"; import { getConnectionConfigLayoutKindLabel, getConnectionConfigSectionCopy, getStoredSecretPlaceholder, normalizeConnectionSecretErrorMessage, resolveConnectionTestFailureFeedback, resolveConnectionConfigLayout, summarizeConnectionTestFailureMessage, type ConnectionConfigSectionKey, } from "../utils/connectionModalPresentation"; import { resolveConnectionSecretDraft } from "../utils/connectionSecretDraft"; import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn"; import { CUSTOM_CONNECTION_DRIVER_HELP } from "../utils/driverImportGuidance"; import { applyNoAutoCapAttributes, noAutoCapInputProps, } from "../utils/inputAutoCap"; import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig, hasUnsupportedJVMDiagnosticTransport, hasUnsupportedJVMEditableModes, JVM_EDITABLE_MODES, normalizeEditableJVMModes, resolveEditableJVMModeSelection, } from "../utils/jvmConnectionConfig"; import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation"; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile, TestJVMConnection, } from "../../wailsjs/go/app/App"; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from "../types"; const { Text } = Typography; type EditableJVMMode = (typeof JVM_EDITABLE_MODES)[number]; type ChoiceCardOption = { value: string; label: string; description?: string; }; type ClickHouseProtocolChoice = "auto" | "http" | "native"; 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 CLICKHOUSE_PROTOCOL_OPTIONS: Array<{ value: ClickHouseProtocolChoice; label: string; }> = [ { value: "auto", label: "自动" }, { value: "http", label: "HTTP" }, { value: "native", label: "Native" }, ]; const normalizeClickHouseProtocolValue = ( value: unknown, ): ClickHouseProtocolChoice => { const text = String(value || "") .trim() .toLowerCase(); if (text === "http" || text === "https") return "http"; if (text === "native" || text === "tcp") return "native"; return "auto"; }; type ConnectionSecretKey = | "primaryPassword" | "sshPassword" | "proxyPassword" | "httpTunnelPassword" | "mysqlReplicaPassword" | "mongoReplicaPassword" | "opaqueURI" | "opaqueDSN"; type ConnectionSecretClearState = Record; const createEmptyConnectionSecretClearState = (): ConnectionSecretClearState => ({ primaryPassword: false, sshPassword: false, proxyPassword: false, httpTunnelPassword: false, mysqlReplicaPassword: false, mongoReplicaPassword: false, opaqueURI: false, opaqueDSN: false, }); const getDefaultPortByType = (type: string) => { switch (type) { case "jvm": return 9010; 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", "doris", "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; expectedRevision?: string; needsUpdate?: boolean; updateReason?: string; affectedConnections?: number; 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 resolveConnectionDriverType = (type: string, driver?: string): string => { const normalizedType = normalizeDriverType(type); if (normalizedType !== "custom") { return normalizedType; } return normalizeDriverType(driver || ""); }; const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null; onOpenDriverManager?: () => void; onSaved?: (savedConnection: SavedConnection) => void | Promise; }> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => { 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" | "appearance" >("basic"); const [customIconType, setCustomIconType] = useState( undefined, ); const [customIconColor, setCustomIconColor] = useState( undefined, ); 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< Record >({}); const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const [selectingDbFile, setSelectingDbFile] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); const [clearSecrets, setClearSecrets] = useState( createEmptyConnectionSecretClearState, ); 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 disableLocalBackdropFilter = isMacLikePlatform(); 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 sslMode = Form.useWatch("sslMode", form) || "preferred"; const proxyType = Form.useWatch("proxyType", form) || "socks5"; const customDriver = Form.useWatch("driver", form) || ""; const mongoReadPreference = Form.useWatch("mongoReadPreference", form) || "primary"; const mongoAuthMechanism = Form.useWatch("mongoAuthMechanism", form) || ""; const jvmEnvironment = Form.useWatch("jvmEnvironment", form) || "dev"; const jvmAllowedModes = Form.useWatch("jvmAllowedModes", form); const jvmPreferredMode = Form.useWatch("jvmPreferredMode", form) || "jmx"; const jvmDiagnosticEnabled = Form.useWatch("jvmDiagnosticEnabled", form) || false; const jvmDiagnosticTransport = Form.useWatch("jvmDiagnosticTransport", form) || "agent-bridge"; const normalizedJvmAllowedModes = useMemo( () => normalizeEditableJVMModes(jvmAllowedModes), [jvmAllowedModes], ); const hasUnsupportedJvmModeSelection = useMemo( () => hasUnsupportedJVMEditableModes({ allowedModes: jvmAllowedModes, preferredMode: jvmPreferredMode, }), [jvmAllowedModes, jvmPreferredMode], ); const isMySQLLike = dbType === "mysql" || dbType === "mariadb" || dbType === "doris" || 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 overlayTheme = useMemo( () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter, }), [darkMode, disableLocalBackdropFilter], ); 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)", }; useEffect(() => { if (!open) return; const applyForConnectionModal = () => { document .querySelectorAll( ".connection-modal-wrap input, .connection-modal-wrap textarea", ) .forEach(applyNoAutoCapAttributes); }; applyForConnectionModal(); const observer = new MutationObserver(() => { applyForConnectionModal(); }); observer.observe(document.body, { childList: true, subtree: true }); return () => { observer.disconnect(); }; }, [open]); const modalShellStyle = useMemo( () => ({ background: overlayTheme.shellBg, border: overlayTheme.shellBorder, boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, }), [overlayTheme], ); const modalInnerSectionStyle = useMemo( () => ({ padding: 14, borderRadius: 14, border: overlayTheme.sectionBorder, background: overlayTheme.sectionBg, }), [overlayTheme], ); const modalMutedTextStyle = useMemo( () => ({ color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, }), [overlayTheme], ); const renderStoredSecretControls = ({ fieldName, clearKey, hasStoredSecret, clearLabel, description, }: { fieldName: string; clearKey: ConnectionSecretKey; hasStoredSecret?: boolean; clearLabel: string; description: string; }) => { if (!initialValues || !hasStoredSecret) { return null; } return ( prev[fieldName] !== next[fieldName]} > {({ getFieldValue }) => { const draftValue = getFieldValue(fieldName); const hasDraftValue = String(draftValue ?? "") !== ""; const cardBorder = darkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(16,24,40,0.08)"; const cardBg = darkMode ? "rgba(255,255,255,0.03)" : "rgba(16,24,40,0.03)"; const effectiveChecked = clearSecrets[clearKey] && !hasDraftValue; return (
{hasDraftValue ? "已输入新值,保存时会替换当前已保存内容。" : description}
{ const checked = event.target.checked; setClearSecrets((prev) => ({ ...prev, [clearKey]: checked })); }} > {clearLabel}
); }}
); }; 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 jvmSectionCardStyle = (): React.CSSProperties => ({ ...modalInnerSectionStyle, padding: 16, }); const renderJvmSectionHeader = ( icon: React.ReactNode, title: string, description: string, badge?: React.ReactNode, ) => (
{icon}
{title}
{description}
{badge ?
{badge}
: null}
); const configSectionCardStyle = (): React.CSSProperties => ({ padding: 16, borderRadius: 16, 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.025)" : "rgba(255,255,255,0.70)", boxShadow: darkMode ? "inset 0 1px 0 rgba(255,255,255,0.04)" : "inset 0 1px 0 rgba(255,255,255,0.90)", }); const renderConfigSectionCard = ({ sectionKey, icon, children, badge, }: { sectionKey: ConnectionConfigSectionKey; icon: React.ReactNode; children: React.ReactNode; badge?: React.ReactNode; }) => { const copy = getConnectionConfigSectionCopy(sectionKey); return (
{renderJvmSectionHeader(icon, copy.title, copy.description, badge)} {children}
); }; const clearConnectionTestResultForChoice = () => { if (testResult) { setTestResult(null); setTestErrorLogOpen(false); } }; const setChoiceFieldValue = (fieldName: string, value: string | boolean) => { clearConnectionTestResultForChoice(); form.setFieldValue(fieldName, value); if ( fieldName === "mongoTopology" || fieldName === "mongoSrv" || fieldName === "host" || fieldName === "port" ) { setMongoMembers([]); } if (fieldName === "redisTopology") { 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 (fieldName === "proxyType") { const nextType = String(value || "socks5").toLowerCase(); const currentPort = Number(form.getFieldValue("proxyPort") || 0); if (nextType === "http") { if (!currentPort || currentPort === 1080) { form.setFieldValue("proxyPort", 8080); } } else if (!currentPort || currentPort === 8080) { form.setFieldValue("proxyPort", 1080); } } }; const renderChoiceCards = ({ fieldName, value, options, minWidth = 180, onSelect, }: { fieldName: string; value: string; options: ChoiceCardOption[]; minWidth?: number; onSelect?: (value: string) => void; }) => ( <>
{options.map((option) => { const active = String(value ?? "") === option.value; return ( ); })}
); const applyJvmModeSelection = ( nextModes: EditableJVMMode[], preferredMode?: EditableJVMMode, ) => { const normalizedModes = normalizeEditableJVMModes(nextModes); const resolvedModes = normalizedModes.length ? normalizedModes : ["jmx"]; const resolvedPreferred = preferredMode && resolvedModes.includes(preferredMode) ? preferredMode : resolvedModes.includes(jvmPreferredMode as EditableJVMMode) ? (jvmPreferredMode as EditableJVMMode) : resolvedModes[0]; form.setFieldsValue({ jvmAllowedModes: resolvedModes, jvmPreferredMode: resolvedPreferred, jvmEndpointEnabled: resolvedModes.includes("endpoint"), jvmAgentEnabled: resolvedModes.includes("agent"), }); }; const handleJvmModeCardSelect = (mode: EditableJVMMode) => { const enabled = normalizedJvmAllowedModes.includes(mode); applyJvmModeSelection( enabled ? normalizedJvmAllowedModes : [...normalizedJvmAllowedModes, mode], mode, ); }; const handleJvmModeToggle = ( mode: EditableJVMMode, event: React.MouseEvent, ) => { event.stopPropagation(); const enabled = normalizedJvmAllowedModes.includes(mode); if (!enabled) { applyJvmModeSelection([...normalizedJvmAllowedModes, mode], mode); return; } if (normalizedJvmAllowedModes.length <= 1) { return; } const nextModes = normalizedJvmAllowedModes.filter((item) => item !== mode); applyJvmModeSelection(nextModes, nextModes[0]); }; const fetchDriverStatusMap = async (): Promise< Record > => { 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, expectedRevision: String(item.expectedRevision || "").trim() || undefined, needsUpdate: !!item.needsUpdate, updateReason: String(item.updateReason || "").trim() || undefined, affectedConnections: Number.isFinite(Number(item.affectedConnections)) ? Number(item.affectedConnections) : undefined, 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, driver?: string, ): Promise => { const normalized = resolveConnectionDriverType(type, driver); 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 normalizeUriBool = (raw: unknown) => { const text = String(raw ?? "") .trim() .toLowerCase(); return text === "1" || text === "true" || text === "yes" || text === "on"; }; 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 parseClickHouseHTTPUriToValues = ( uriText: string, fallbackPort?: number, ): Record | null => { const trimmed = String(uriText || "").trim(); const lower = trimmed.toLowerCase(); const isHttps = lower.startsWith("https://"); const isHttp = lower.startsWith("http://"); if (!isHttp && !isHttps) { return null; } const defaultPort = Number.isFinite(Number(fallbackPort)) && Number(fallbackPort) > 0 ? Number(fallbackPort) : isHttps ? 8443 : 8123; const parsed = parseSingleHostUri( trimmed, [isHttps ? "https" : "http"], defaultPort, ); if (!parsed) { return null; } const skipVerify = normalizeUriBool(parsed.params.get("skip_verify")); return { host: parsed.host, port: parsed.port, user: parsed.username, password: parsed.password, database: parsed.database || "", clickHouseProtocol: "http", useSSL: isHttps, sslMode: isHttps ? (skipVerify ? "skip-verify" : "required") : "disable", }; }; 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, user: parsed.username || "", 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, }; } if (type === "clickhouse") { const httpValues = parseClickHouseHTTPUriToValues(trimmedUri); if (httpValues) { return httpValues; } } 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") { parsedValues.clickHouseProtocol = normalizeClickHouseProtocolValue( parsed.params.get("protocol"), ); 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 createCustomDsnRule = () => ({ validator(_: unknown, value: unknown) { const validationMessage = getCustomConnectionDsnValidationMessage({ dsnInput: value, hasStoredSecret: initialValues?.hasOpaqueDSN, clearStoredSecret: clearSecrets.opaqueDSN, }); return validationMessage ? Promise.reject(new Error(validationMessage)) : Promise.resolve(); }, }); 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 redisUser = String(values.user || "").trim(); const redisPassword = String(values.password || ""); let redisAuth = ""; if (redisUser || redisPassword) { const encodedPassword = redisPassword ? encodeURIComponent(redisPassword) : ""; redisAuth = redisUser ? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ""}@` : `:${encodedPassword}@`; } 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 clickHouseProtocol = type === "clickhouse" ? normalizeClickHouseProtocolValue(values.clickHouseProtocol) : "auto"; const scheme = type === "postgres" ? "postgresql" : type === "clickhouse" && clickHouseProtocol === "http" ? values.useSSL ? "https" : "http" : 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") { if (clickHouseProtocol === "http") { if (mode === "skip-verify" || mode === "preferred") { params.set("skip_verify", "true"); } } else { 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"); } } if (type === "clickhouse" && clickHouseProtocol !== "auto") { params.set("protocol", clickHouseProtocol); } 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 !== "已取消") { 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 !== "已取消") { message.error(`选择数据库文件失败: ${res?.message || "未知错误"}`); } } catch (e: any) { message.error(`选择数据库文件失败: ${e?.message || String(e)}`); } finally { setSelectingDbFile(false); } }; useEffect(() => { if (open) { setLoading(false); testInFlightRef.current = false; if (testTimerRef.current !== null) { window.clearTimeout(testTimerRef.current); testTimerRef.current = null; } setTestResult(null); // Reset test result setTestErrorLogOpen(false); setDbList([]); setRedisDbList([]); setMongoMembers([]); setUriFeedback(null); setCustomIconType(undefined); setCustomIconColor(undefined); setClearSecrets(createEmptyConnectionSecretClearState()); 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 isJvmConfigType = configType === "jvm"; const defaultPort = getDefaultPortByType(configType); const isFileDbConfigType = isFileDatabaseType(configType); const jvmDefaultValues = buildDefaultJVMConnectionValues(); 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 { allowedModes: resolvedJvmAllowedModes, preferredMode: resolvedJvmPreferredMode, } = resolveEditableJVMModeSelection({ allowedModes: config.jvm?.allowedModes, preferredMode: config.jvm?.preferredMode, }); const resolvedJvmTimeout = isJvmConfigType ? Number(config.jvm?.endpoint?.timeoutSeconds || config.timeout || 30) : Number(config.timeout || 30); 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 || "", clickHouseProtocol: configType === "clickhouse" ? normalizeClickHouseProtocolValue(config.clickHouseProtocol) : "auto", 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: resolvedJvmTimeout, 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 || "", jvmReadOnly: isJvmConfigType ? (config.jvm?.readOnly ?? jvmDefaultValues.jvmReadOnly) : jvmDefaultValues.jvmReadOnly, jvmAllowedModes: isJvmConfigType ? resolvedJvmAllowedModes : jvmDefaultValues.jvmAllowedModes, jvmPreferredMode: isJvmConfigType ? resolvedJvmPreferredMode : jvmDefaultValues.jvmPreferredMode, jvmEnvironment: isJvmConfigType ? config.jvm?.environment || jvmDefaultValues.jvmEnvironment : jvmDefaultValues.jvmEnvironment, jvmEndpointEnabled: isJvmConfigType ? (config.jvm?.endpoint?.enabled ?? resolvedJvmAllowedModes.includes("endpoint")) : jvmDefaultValues.jvmEndpointEnabled, jvmEndpointBaseUrl: isJvmConfigType ? config.jvm?.endpoint?.baseUrl || "" : jvmDefaultValues.jvmEndpointBaseUrl, jvmEndpointApiKey: isJvmConfigType ? config.jvm?.endpoint?.apiKey || "" : jvmDefaultValues.jvmEndpointApiKey, jvmAgentEnabled: isJvmConfigType ? (config.jvm?.agent?.enabled ?? resolvedJvmAllowedModes.includes("agent")) : jvmDefaultValues.jvmAgentEnabled, jvmAgentBaseUrl: isJvmConfigType ? config.jvm?.agent?.baseUrl || "" : jvmDefaultValues.jvmAgentBaseUrl, jvmAgentApiKey: isJvmConfigType ? config.jvm?.agent?.apiKey || "" : jvmDefaultValues.jvmAgentApiKey, jvmDiagnosticEnabled: isJvmConfigType ? (config.jvm?.diagnostic?.enabled ?? jvmDefaultValues.jvmDiagnosticEnabled) : jvmDefaultValues.jvmDiagnosticEnabled, jvmDiagnosticTransport: isJvmConfigType ? config.jvm?.diagnostic?.transport || jvmDefaultValues.jvmDiagnosticTransport : jvmDefaultValues.jvmDiagnosticTransport, jvmDiagnosticBaseUrl: isJvmConfigType ? config.jvm?.diagnostic?.baseUrl || "" : jvmDefaultValues.jvmDiagnosticBaseUrl, jvmDiagnosticTargetId: isJvmConfigType ? config.jvm?.diagnostic?.targetId || "" : jvmDefaultValues.jvmDiagnosticTargetId, jvmDiagnosticApiKey: isJvmConfigType ? config.jvm?.diagnostic?.apiKey || "" : jvmDefaultValues.jvmDiagnosticApiKey, jvmDiagnosticAllowObserveCommands: isJvmConfigType ? (config.jvm?.diagnostic?.allowObserveCommands ?? jvmDefaultValues.jvmDiagnosticAllowObserveCommands) : jvmDefaultValues.jvmDiagnosticAllowObserveCommands, jvmDiagnosticAllowTraceCommands: isJvmConfigType ? (config.jvm?.diagnostic?.allowTraceCommands ?? jvmDefaultValues.jvmDiagnosticAllowTraceCommands) : jvmDefaultValues.jvmDiagnosticAllowTraceCommands, jvmDiagnosticAllowMutatingCommands: isJvmConfigType ? (config.jvm?.diagnostic?.allowMutatingCommands ?? jvmDefaultValues.jvmDiagnosticAllowMutatingCommands) : jvmDefaultValues.jvmDiagnosticAllowMutatingCommands, jvmDiagnosticTimeoutSeconds: isJvmConfigType ? Number( config.jvm?.diagnostic?.timeoutSeconds || jvmDefaultValues.jvmDiagnosticTimeoutSeconds, ) : jvmDefaultValues.jvmDiagnosticTimeoutSeconds, jvmEndpointTimeoutSeconds: resolvedJvmTimeout, jvmJmxHost: isJvmConfigType && config.jvm?.jmx?.host && config.jvm.jmx.host !== primaryHost ? config.jvm.jmx.host : "", jvmJmxPort: isJvmConfigType && Number(config.jvm?.jmx?.port) > 0 && Number(config.jvm.jmx.port) !== Number(primaryPort || defaultPort) ? Number(config.jvm.jmx.port) : undefined, jvmJmxUsername: isJvmConfigType ? config.jvm?.jmx?.username || "" : "", jvmJmxPassword: isJvmConfigType ? config.jvm?.jmx?.password || "" : "", }); setUseSSL(!!config.useSSL); setCustomIconType(initialValues.iconType); setCustomIconColor(initialValues.iconColor); 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 buildSavedConnectionInput = (config: ConnectionConfig, values: any) => { const connectionId = initialValues?.id || config.id || Date.now().toString(); const primaryDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasPrimaryPassword, valueInput: config.password, clearSecret: clearSecrets.primaryPassword, forceClear: values.type === "mongodb" && values.savePassword === false, }); const sshDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasSSHPassword, valueInput: config.ssh?.password, clearSecret: clearSecrets.sshPassword, forceClear: !config.useSSH, }); const proxyDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasProxyPassword, valueInput: config.proxy?.password, clearSecret: clearSecrets.proxyPassword, forceClear: !config.useProxy, }); const httpTunnelDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasHttpTunnelPassword, valueInput: config.httpTunnel?.password, clearSecret: clearSecrets.httpTunnelPassword, forceClear: !config.useHttpTunnel, }); const mysqlReplicaEnabled = (config.type === "mysql" || config.type === "mariadb" || config.type === "diros" || config.type === "sphinx") && config.topology === "replica"; const mysqlReplicaDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasMySQLReplicaPassword, valueInput: config.mysqlReplicaPassword, clearSecret: clearSecrets.mysqlReplicaPassword, forceClear: !mysqlReplicaEnabled, }); const mongoReplicaEnabled = config.type === "mongodb" && config.topology === "replica" && values.savePassword !== false; const mongoReplicaDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasMongoReplicaPassword, valueInput: config.mongoReplicaPassword, clearSecret: clearSecrets.mongoReplicaPassword, forceClear: !mongoReplicaEnabled, }); const opaqueUriDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasOpaqueURI, valueInput: config.uri, clearSecret: clearSecrets.opaqueURI, forceClear: values.type === "custom", trimInput: true, }); const opaqueDsnDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasOpaqueDSN, valueInput: config.dsn, clearSecret: clearSecrets.opaqueDSN, forceClear: values.type !== "custom", trimInput: true, }); const isRedisType = values.type === "redis"; const displayHost = String( (config as any).host || values.host || "", ).trim(); const nextName = values.name || (isFileDatabaseType(values.type) ? values.type === "duckdb" ? "DuckDB DB" : "SQLite DB" : values.type === "redis" ? `Redis ${displayHost}` : displayHost); return { id: connectionId, name: nextName, config: { ...config, id: connectionId, password: primaryDraft.value, ssh: { ...(config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "", }), password: sshDraft.value, }, proxy: { ...(config.proxy || { type: "socks5", host: "", port: 1080, user: "", password: "", }), password: proxyDraft.value, }, httpTunnel: { ...(config.httpTunnel || { host: "", port: 8080, user: "", password: "", }), password: httpTunnelDraft.value, }, uri: opaqueUriDraft.value, dsn: opaqueDsnDraft.value, mysqlReplicaPassword: mysqlReplicaDraft.value, mongoReplicaPassword: mongoReplicaDraft.value, }, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined, iconType: customIconType || "", iconColor: customIconColor || "", clearPrimaryPassword: primaryDraft.clearStoredSecret, clearSSHPassword: sshDraft.clearStoredSecret, clearProxyPassword: proxyDraft.clearStoredSecret, clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, clearOpaqueURI: opaqueUriDraft.clearStoredSecret, clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, }; }; const handleOk = async () => { try { await form.validateFields(); const values = form.getFieldsValue(true); const unavailableReason = await resolveDriverUnavailableReason( values.type, values.driver, ); if (unavailableReason) { message.warning(unavailableReason); promptInstallDriver( resolveConnectionDriverType(values.type, values.driver) || values.type, unavailableReason, ); return; } setLoading(true); const config = await buildConfig(values, true); const payload = buildSavedConnectionInput(config, values); const backendApp = (window as any).go?.app?.App; const savedConnection = await backendApp?.SaveConnection?.(payload); if (!savedConnection) { throw new Error("保存连接失败:后端接口不可用"); } if (initialValues) { updateConnection(savedConnection); message.success("配置已更新(未连接)"); } else { addConnection(savedConnection); message.success("配置已保存(未连接)"); } if (onSaved) { void Promise.resolve(onSaved(savedConnection)).catch( (error: unknown) => { console.warn("Failed to refresh post-save state", error); void message.warning( "配置已保存,但安全更新状态暂未刷新,请稍后重新检查", ); }, ); } form.resetFields(); setUseSSL(false); setUseSSH(false); setUseProxy(false); setUseHttpTunnel(false); setDbType("mysql"); setStep(1); setClearSecrets(createEmptyConnectionSecretClearState()); onClose(); } catch (e: any) { message.error( normalizeConnectionSecretErrorMessage(e?.message || e, "保存失败"), ); } finally { setLoading(false); } }; const requestTest = () => { if (loading) return; if (testTimerRef.current !== null) return; testTimerRef.current = window.setTimeout(() => { testTimerRef.current = null; handleTest(); }, 0); }; const withClientTimeout = async ( promise: Promise, timeoutMs: number, timeoutMessage: string, ): Promise => { let timer: number | null = null; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = window.setTimeout( () => reject(new Error(timeoutMessage)), timeoutMs, ); }), ]); } finally { if (timer !== null) { window.clearTimeout(timer); } } }; const getBlockingSecretClearMessage = (values: any): string | null => { if ( clearSecrets.primaryPassword && values.type !== "custom" && !isFileDatabaseType(values.type) && String(values.password ?? "") === "" ) { return "测试连接前请填写新的密码,或取消清除已保存密码"; } if ( clearSecrets.sshPassword && values.useSSH && String(values.sshPassword ?? "") === "" ) { return "测试连接前请填写新的 SSH 密码,或取消清除已保存 SSH 密码"; } if ( clearSecrets.proxyPassword && values.useProxy && !values.useHttpTunnel && String(values.proxyPassword ?? "") === "" ) { return "测试连接前请填写新的代理密码,或取消清除已保存代理密码"; } if ( clearSecrets.httpTunnelPassword && values.useHttpTunnel && String(values.httpTunnelPassword ?? "") === "" ) { return "测试连接前请填写新的隧道密码,或取消清除已保存隧道密码"; } if ( clearSecrets.mysqlReplicaPassword && (values.type === "mysql" || values.type === "mariadb" || values.type === "diros" || values.type === "sphinx") && values.mysqlTopology === "replica" && String(values.mysqlReplicaPassword ?? "") === "" ) { return "测试连接前请填写新的从库密码,或取消清除已保存从库密码"; } if ( clearSecrets.mongoReplicaPassword && values.type === "mongodb" && values.mongoTopology === "replica" && String(values.mongoReplicaPassword ?? "") === "" ) { return "测试连接前请填写新的副本集密码,或取消清除已保存副本集密码"; } if ( values.type === "mongodb" && values.savePassword === false && initialValues?.hasPrimaryPassword && String(values.password ?? "") === "" ) { return "测试连接前请填写新的 MongoDB 密码,或重新勾选保存密码"; } return null; }; const applyTestFailureFeedback = (feedback: { message: string }) => { void message.destroy("connection-test-failure"); setTestResult({ type: "error", message: feedback.message }); }; const handleTest = async () => { if (testInFlightRef.current) return; testInFlightRef.current = true; try { await form.validateFields(); const values = form.getFieldsValue(true); const unavailableReason = await resolveDriverUnavailableReason( values.type, values.driver, ); if (unavailableReason) { applyTestFailureFeedback( resolveConnectionTestFailureFeedback({ kind: "driver_unavailable", reason: unavailableReason, fallback: "驱动未安装启用", }), ); promptInstallDriver( resolveConnectionDriverType(values.type, values.driver) || values.type, unavailableReason, ); return; } const blockingSecretClearMessage = getBlockingSecretClearMessage(values); if (blockingSecretClearMessage) { applyTestFailureFeedback( resolveConnectionTestFailureFeedback({ kind: "secret_blocked", reason: blockingSecretClearMessage, fallback: "连接参数不完整", }), ); return; } setLoading(true); setTestResult(null); const config = await buildConfig(values, false); if (initialValues?.id) { config.id = initialValues.id; } const timeoutSecondsRaw = Number(values.timeout); const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0 ? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS) : 30; const rpcTimeoutMs = (timeoutSeconds + 5) * 1000; // Use different API for Redis / JVM const isRedisType = values.type === "redis"; const isJVMType = values.type === "jvm"; const res = await withClientTimeout( isJVMType ? TestJVMConnection(config as any) : isRedisType ? RedisConnect(config as any) : TestConnection(config as any), rpcTimeoutMs, `连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`, ); if (res.success) { void message.destroy("connection-test-failure"); setTestResult({ type: "success", message: res.message }); if (isRedisType) { setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); } else if (!isJVMType) { // Other databases: fetch database list const dbRes = await withClientTimeout( DBGetDatabases(config as any), rpcTimeoutMs, `连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`, ); 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( `连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, "未知错误")}`, ); } } } else { applyTestFailureFeedback( resolveConnectionTestFailureFeedback({ kind: "runtime", reason: res?.message, fallback: "连接被拒绝或参数无效,请检查后重试", }), ); } } catch (e: unknown) { if (e && typeof e === "object" && "errorFields" in e) { applyTestFailureFeedback( resolveConnectionTestFailureFeedback({ kind: "validation", reason: "", fallback: "请先完善必填项后再测试连接", }), ); return; } const reason = e instanceof Error ? e.message : typeof e === "string" ? e : "未知异常"; applyTestFailureFeedback( resolveConnectionTestFailureFeedback({ kind: "runtime", reason, fallback: "未知异常", }), ); } finally { testInFlightRef.current = false; setLoading(false); } }; const handleDiscoverMongoMembers = async () => { if (discoveringMembers || dbType !== "mongodb") { return; } try { await form.validateFields(); const values = form.getFieldsValue(true); setDiscoveringMembers(true); const blockingSecretClearMessage = getBlockingSecretClearMessage(values); if (blockingSecretClearMessage) { message.error(blockingSecretClearMessage); return; } const config = await buildConfig(values, false); if (initialValues?.id) { config.id = initialValues.id; } const result = await MongoDiscoverMembers(config as any); if (!result.success) { message.error( normalizeConnectionSecretErrorMessage(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( normalizeConnectionSecretErrorMessage( error?.message || error, "成员发现失败", ), ); } finally { setDiscoveringMembers(false); } }; const buildConfig = async ( values: any, forPersist: boolean, ): Promise => { const mergedValues = { ...values }; if ( String(mergedValues.type || "") .trim() .toLowerCase() === "jvm" ) { if ( hasUnsupportedJVMEditableModes({ allowedModes: mergedValues.jvmAllowedModes, preferredMode: mergedValues.jvmPreferredMode, }) ) { throw new Error( "当前连接包含未支持的 JVM 模式;请先调整为 JMX、Endpoint 或 Agent 后再测试或保存", ); } if ( hasUnsupportedJVMDiagnosticTransport( mergedValues.jvmDiagnosticTransport, ) ) { throw new Error( "当前连接包含未支持的 JVM 诊断 transport;请先调整为 agent-bridge 或 arthas-tunnel 后再测试或保存", ); } const existingDiagnostic = initialValues?.config?.jvm?.diagnostic; if ( mergedValues.jvmDiagnosticEnabled === undefined && existingDiagnostic?.enabled !== undefined ) { mergedValues.jvmDiagnosticEnabled = existingDiagnostic.enabled; } if ( String(mergedValues.jvmDiagnosticTransport || "").trim() === "" && existingDiagnostic?.transport ) { mergedValues.jvmDiagnosticTransport = existingDiagnostic.transport; } if ( String(mergedValues.jvmDiagnosticBaseUrl || "").trim() === "" && existingDiagnostic?.baseUrl ) { mergedValues.jvmDiagnosticBaseUrl = existingDiagnostic.baseUrl; } if ( String(mergedValues.jvmDiagnosticTargetId || "").trim() === "" && existingDiagnostic?.targetId ) { mergedValues.jvmDiagnosticTargetId = existingDiagnostic.targetId; } if ( String(mergedValues.jvmDiagnosticApiKey || "").trim() === "" && existingDiagnostic?.apiKey ) { mergedValues.jvmDiagnosticApiKey = existingDiagnostic.apiKey; } if ( mergedValues.jvmDiagnosticAllowObserveCommands === undefined && existingDiagnostic?.allowObserveCommands !== undefined ) { mergedValues.jvmDiagnosticAllowObserveCommands = existingDiagnostic.allowObserveCommands; } if ( mergedValues.jvmDiagnosticAllowTraceCommands === undefined && existingDiagnostic?.allowTraceCommands !== undefined ) { mergedValues.jvmDiagnosticAllowTraceCommands = existingDiagnostic.allowTraceCommands; } if ( mergedValues.jvmDiagnosticAllowMutatingCommands === undefined && existingDiagnostic?.allowMutatingCommands !== undefined ) { mergedValues.jvmDiagnosticAllowMutatingCommands = existingDiagnostic.allowMutatingCommands; } if ( (mergedValues.jvmDiagnosticTimeoutSeconds === undefined || mergedValues.jvmDiagnosticTimeoutSeconds === null || mergedValues.jvmDiagnosticTimeoutSeconds === "") && Number(existingDiagnostic?.timeoutSeconds) > 0 ) { mergedValues.jvmDiagnosticTimeoutSeconds = Number( existingDiagnostic?.timeoutSeconds, ); } const resolvedJvmAllowedModes = normalizeEditableJVMModes( mergedValues.jvmAllowedModes, ); const resolvedJvmTimeout = Number(mergedValues.timeout || 30); const preferredJvmMode = String(mergedValues.jvmPreferredMode || "") .trim() .toLowerCase(); const resolvedJvmPreferredMode = resolvedJvmAllowedModes.find((mode) => mode === preferredJvmMode) || resolvedJvmAllowedModes[0]; return buildJVMConnectionConfig({ ...buildDefaultJVMConnectionValues(), ...mergedValues, jvmAllowedModes: resolvedJvmAllowedModes, jvmPreferredMode: resolvedJvmPreferredMode, jvmEndpointEnabled: resolvedJvmAllowedModes.includes("endpoint"), jvmAgentEnabled: resolvedJvmAllowedModes.includes("agent"), timeout: resolvedJvmTimeout, jvmEndpointTimeoutSeconds: resolvedJvmTimeout, }); } 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 ( key === "clickHouseProtocol" && normalizeClickHouseProtocolValue((mergedValues as any)[key]) === "auto" && normalizeClickHouseProtocolValue(value) !== "auto" ) { (mergedValues as any)[key] = value; return; } if (isEmptyField((mergedValues as any)[key])) { (mergedValues as any)[key] = value; } }); } const type = String(mergedValues.type || "").toLowerCase(); const defaultPort = getDefaultPortByType(type); if (type === "clickhouse") { const requestedProtocol = normalizeClickHouseProtocolValue( mergedValues.clickHouseProtocol, ); const hostSchemeValues = parseClickHouseHTTPUriToValues( mergedValues.host, Number(mergedValues.port || defaultPort), ); if (hostSchemeValues) { mergedValues.host = hostSchemeValues.host; mergedValues.port = hostSchemeValues.port; if (requestedProtocol !== "native") { mergedValues.clickHouseProtocol = "http"; mergedValues.useSSL = hostSchemeValues.useSSL; mergedValues.sslMode = hostSchemeValues.sslMode; } else { mergedValues.clickHouseProtocol = "native"; } if (isEmptyField(mergedValues.user)) { mergedValues.user = hostSchemeValues.user; } if (isEmptyField(mergedValues.password)) { mergedValues.password = hostSchemeValues.password; } if (isEmptyField(mergedValues.database)) { mergedValues.database = hostSchemeValues.database; } } } const isFileDbType = isFileDatabaseType(type); const sslCapableType = supportsSSLForType(type); // Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user, // 同时清理历史默认值 root,避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。 if (type === "redis") { if ( parsedUriValues && Object.prototype.hasOwnProperty.call(parsedUriValues, "user") ) { mergedValues.user = String((parsedUriValues as any).user || ""); } else if (String(mergedValues.user || "").trim() === "root") { mergedValues.user = ""; } } 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(), clickHouseProtocol: type === "clickhouse" ? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol) : undefined, 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 = (type: string) => { const normalized = normalizeDriverType(type); const snapshot = driverStatusMap[normalized]; if (snapshot && !snapshot.connectable) { const driverName = snapshot.name || type; const reason = snapshot.message || `${driverName} 驱动未安装启用,请先在驱动管理中安装`; setTypeSelectWarning({ driverName, reason }); return; } setTypeSelectWarning(null); setDbType(type); form.setFieldsValue({ type: type, clickHouseProtocol: type === "clickhouse" ? "auto" : undefined, }); const defaultPort = getDefaultPortByType(type); if (type === "jvm") { const jvmDefaultValues = buildDefaultJVMConnectionValues(); setUseSSL(false); setUseSSH(false); setUseProxy(false); setUseHttpTunnel(false); form.setFieldsValue({ ...jvmDefaultValues, user: "", password: "", database: "", useSSL: false, sslMode: undefined, sslCertPath: undefined, sslKeyPath: undefined, useSSH: false, sshHost: "", sshPort: 22, sshUser: "", sshPassword: "", sshKeyPath: "", useProxy: false, proxyType: "socks5", proxyHost: "", proxyPort: 1080, proxyUser: "", proxyPassword: "", useHttpTunnel: false, httpTunnelHost: "", httpTunnelPort: 8080, httpTunnelUser: "", httpTunnelPassword: "", timeout: 30, uri: "", includeDatabases: undefined, includeRedisDatabases: undefined, mysqlTopology: "single", redisTopology: "single", mongoTopology: "single", mongoSrv: false, mongoReadPreference: "primary", mongoReplicaSet: "", mongoAuthSource: "", mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], redisHosts: [], mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", mongoReplicaUser: "", mongoReplicaPassword: "", redisDB: 0, jvmEndpointTimeoutSeconds: 30, jvmJmxHost: "", jvmJmxPort: undefined, jvmJmxUsername: "", jvmJmxPassword: "", jvmAgentEnabled: false, jvmAgentBaseUrl: "", jvmAgentApiKey: "", }); } else 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" : type === "redis" ? "" : "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); if (!driverStatusLoaded || !snapshot) { void refreshDriverStatus(); } }; const isFileDb = isFileDatabaseType(dbType); const isCustom = dbType === "custom"; const isRedis = dbType === "redis"; const isJVM = dbType === "jvm"; const connectionConfigLayout = resolveConnectionConfigLayout(dbType); const unsupportedJvmModeMessage = isJVM && hasUnsupportedJvmModeSelection ? "当前连接包含未支持的 JVM 模式。此版本只支持 JMX / Endpoint / Agent,请先调整允许模式和首选模式后再继续。" : ""; const currentDriverType = resolveConnectionDriverType(dbType, customDriver); const hasCurrentDriverType = currentDriverType !== "" && currentDriverType !== "custom"; const currentDriverSnapshot = driverStatusMap[currentDriverType]; const currentDriverUnavailableReason = hasCurrentDriverType && currentDriverSnapshot && !currentDriverSnapshot.connectable ? currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用` : ""; const currentDriverUpdateReason = hasCurrentDriverType && currentDriverSnapshot?.connectable && currentDriverSnapshot.needsUpdate ? currentDriverSnapshot.message || currentDriverSnapshot.updateReason || `${currentDriverSnapshot.name || dbType} 驱动代理需要重装后才能应用当前版本的驱动侧更新` : ""; const driverStatusChecking = hasCurrentDriverType && !driverStatusLoaded && step === 2; const dbTypeGroups = [ { label: "关系型数据库", items: [ { key: "mysql", name: "MySQL", icon: getDbIcon("mysql", undefined, 36), }, { key: "mariadb", name: "MariaDB", icon: getDbIcon("mariadb", undefined, 36), }, { key: "diros", name: "Doris", icon: getDbIcon("diros", undefined, 36), }, { key: "sphinx", name: "Sphinx", icon: getDbIcon("sphinx", undefined, 36), }, { key: "clickhouse", name: "ClickHouse", icon: getDbIcon("clickhouse", undefined, 36), }, { key: "postgres", name: "PostgreSQL", icon: getDbIcon("postgres", undefined, 36), }, { key: "sqlserver", name: "SQL Server", icon: getDbIcon("sqlserver", undefined, 36), }, { key: "sqlite", name: "SQLite", icon: getDbIcon("sqlite", undefined, 36), }, { key: "duckdb", name: "DuckDB", icon: getDbIcon("duckdb", undefined, 36), }, { key: "oracle", name: "Oracle", icon: getDbIcon("oracle", undefined, 36), }, ], }, { label: "国产数据库", items: [ { key: "dameng", name: "Dameng (达梦)", icon: getDbIcon("dameng", undefined, 36), }, { key: "kingbase", name: "Kingbase (人大金仓)", icon: getDbIcon("kingbase", undefined, 36), }, { key: "highgo", name: "HighGo (瀚高)", icon: getDbIcon("highgo", undefined, 36), }, { key: "vastbase", name: "Vastbase (海量)", icon: getDbIcon("vastbase", undefined, 36), }, ], }, { label: "NoSQL", items: [ { key: "mongodb", name: "MongoDB", icon: getDbIcon("mongodb", undefined, 36), }, { key: "redis", name: "Redis", icon: getDbIcon("redis", undefined, 36), }, ], }, { label: "时序数据库", items: [ { key: "tdengine", name: "TDengine", icon: getDbIcon("tdengine", undefined, 36), }, ], }, { label: "其他", items: [ { key: "jvm", name: "JVM Runtime", icon: getDbIcon("jvm", undefined, 36), }, { key: "custom", name: "Custom (自定义)", icon: getDbIcon("custom", undefined, 36), }, ], }, ]; const dbTypes = dbTypeGroups.flatMap((g) => g.items); const getDbTypeHint = (type: string) => { switch (type) { case "jvm": return "JMX / Endpoint / Agent"; case "custom": return "自定义驱动与 DSN"; case "redis": return "单机 / 集群"; case "mongodb": return "单机 / 副本集"; case "sqlite": case "duckdb": return "本地文件连接"; default: return "标准连接配置"; } }; const renderStep1 = () => (
选择数据源
先选择目标数据库或中间件类型,再进入详细连接参数配置。
{typeSelectWarning && ( {typeSelectWarning.reason} } onClose={() => setTypeSelectWarning(null)} /> )}
{/* 左侧分类导航 */}
{dbTypeGroups.map((group, idx) => (
setActiveGroup(idx)} style={{ padding: "11px 12px", cursor: "pointer", borderRadius: 12, marginBottom: 6, background: activeGroup === idx ? step1SidebarActiveBg : "transparent", color: activeGroup === idx ? step1SidebarActiveColor : undefined, fontWeight: activeGroup === idx ? 700 : 500, transition: "all 0.2s", fontSize: 13, }} > {group.label}
))}
{/* 右侧数据源卡片 */}
{dbTypeGroups[activeGroup]?.items.map((item) => ( { void handleTypeSelect(item.key); }} style={{ cursor: "pointer", minHeight: 92, borderRadius: 16, 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.80)", }} styles={{ body: { padding: 14, display: "flex", alignItems: "flex-start", gap: 12, height: "100%", }, }} >
{item.icon}
{item.name} {getDbTypeHint(item.key)}
))}
); const renderStep2 = () => { const baseInfoSection = (
基础信息
常用参数集中在左侧,优先完成连接建立所需的最小输入。
{renderConfigSectionCard({ sectionKey: "identity", icon: , badge: ( {getConnectionConfigLayoutKindLabel(connectionConfigLayout.kind)} ), children: ( ), })} {!isCustom && !isJVM && renderConfigSectionCard({ sectionKey: "uri", icon: , children: ( <> {uriFeedback && ( setUriFeedback(null)} style={{ marginBottom: 16 }} /> )} {renderStoredSecretControls({ fieldName: "uri", clearKey: "opaqueURI", hasStoredSecret: initialValues?.hasOpaqueURI, clearLabel: "清除已保存 URI", description: "当前已保存连接 URI。留空表示继续沿用,输入新值表示替换。", })} ), })} {isCustom ? ( <> {renderConfigSectionCard({ sectionKey: "customDriver", icon: , children: ( ), })} {renderConfigSectionCard({ sectionKey: "customDsn", icon: , children: ( <> {renderStoredSecretControls({ fieldName: "dsn", clearKey: "opaqueDSN", hasStoredSecret: initialValues?.hasOpaqueDSN, clearLabel: "清除已保存 DSN", description: "当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。", })} ), })} ) : isJVM ? ( <> {unsupportedJvmModeMessage && ( )}
{renderJvmSectionHeader( , "目标 JVM", "定义连接树中的主机入口和基础运行环境。", )}
环境 {renderChoiceCards({ fieldName: "jvmEnvironment", value: String(jvmEnvironment), minWidth: 120, options: [ { value: "dev", label: "开发 / 测试", description: "本地或测试环境。", }, { value: "uat", label: "预发 / 验收", description: "上线前验证环境。", }, { value: "prod", label: "生产", description: "生产 JVM,默认更谨慎。", }, ], })}
只读优先
{renderJvmSectionHeader( , "接入模式", "通过卡片选择允许使用的 JVM 通道;已启用卡片再次点击会设为首选。", )}
{JVM_EDITABLE_MODES.map((mode) => { const meta = resolveJVMModeMeta(mode); const enabled = normalizedJvmAllowedModes.includes(mode); const preferred = jvmPreferredMode === mode; return (
handleJvmModeCardSelect(mode)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleJvmModeCardSelect(mode); } }} aria-pressed={enabled} style={{ textAlign: "left", padding: 14, borderRadius: 16, border: enabled ? darkMode ? "1px solid rgba(255,214,102,0.36)" : "1px solid rgba(22,119,255,0.34)" : darkMode ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(16,24,40,0.08)", background: enabled ? darkMode ? "rgba(255,214,102,0.08)" : "rgba(22,119,255,0.06)" : darkMode ? "rgba(255,255,255,0.03)" : "rgba(16,24,40,0.03)", boxShadow: preferred ? darkMode ? "0 0 0 2px rgba(255,214,102,0.12)" : "0 0 0 2px rgba(22,119,255,0.10)" : "none", color: darkMode ? "#f5f7ff" : "#162033", cursor: "pointer", transition: "all 120ms ease", }} > {meta.label} {preferred ? 首选 : null} {!enabled ? 未启用 : null}
{mode === "jmx" ? "标准 MBean 与线程、内存、类加载等运行时指标。" : mode === "endpoint" ? "通过服务端管理接口读取 JVM 资源与配置。" : "通过 GoNavi Java Agent 提供更完整的增强能力。"}
); })}
当前首选: {resolveJVMModeMeta(String(jvmPreferredMode || "jmx")).label} 。至少保留一种接入模式,停用首选模式时会自动切换到剩余模式。
{renderJvmSectionHeader( , "JMX", "标准 JVM 管理通道,可覆盖主机/端口并配置认证。", {normalizedJvmAllowedModes.includes("jmx") ? "已启用" : "未启用"} , )}
{renderJvmSectionHeader( , "Endpoint", "连接应用暴露的 JVM 管理端点,适合已有运维 API 的服务。", {normalizedJvmAllowedModes.includes("endpoint") ? "已启用" : "未启用"} , )}
{renderJvmSectionHeader( , "Agent", "连接 GoNavi Java Agent 管理端口,用于增强采集和诊断链路。", {normalizedJvmAllowedModes.includes("agent") ? "已启用" : "未启用"} , )}
{renderJvmSectionHeader( , "诊断增强", "开启后可创建 JVM 诊断会话并执行受控 Arthas/诊断命令。", , )} {jvmDiagnosticEnabled ? ( <>
诊断传输 {renderChoiceCards({ fieldName: "jvmDiagnosticTransport", value: String(jvmDiagnosticTransport), options: [ { value: "agent-bridge", label: "Agent Bridge", description: "通过 GoNavi Agent 桥接诊断命令。", }, { value: "arthas-tunnel", label: "Arthas Tunnel", description: "连接官方 Tunnel / Web Console。", }, ], })}
{[ { name: "jvmDiagnosticAllowObserveCommands", label: "观察类命令", description: "thread、dashboard、jvm 等只读排查命令。", }, { name: "jvmDiagnosticAllowTraceCommands", label: "跟踪类命令", description: "trace、watch 等对目标有额外开销的命令。", }, { name: "jvmDiagnosticAllowMutatingCommands", label: "高风险命令", description: "可能改变运行态或造成明显性能影响的命令。", }, ].map((item) => (
{item.label}
{item.description}
))}
) : (
关闭时只保存 JVM 连接与监控能力,不显示诊断会话入口。
)}
) : ( <> {renderConfigSectionCard({ sectionKey: isFileDb ? "fileTarget" : "target", icon: isFileDb ? : , children: (
{isFileDb ? ( ) : ( Number(value) > 0, ), ]} style={{ marginBottom: 0 }} > )}
), })} {dbType === "clickhouse" && renderConfigSectionCard({ sectionKey: "connectionMode", icon: , children: ( ), })} {dbType === "oracle" && renderConfigSectionCard({ sectionKey: "service", icon: , children: ( ), })} {isMySQLLike && renderConfigSectionCard({ sectionKey: "connectionMode", icon: , children: renderChoiceCards({ fieldName: "mysqlTopology", value: String(mysqlTopology), options: [ { value: "single", label: "单机模式", description: "只连接一个主库地址,适合本地和单实例。", }, { value: "replica", label: "主从模式", description: "主库优先,可配置从库地址用于切换。", }, ], }), })} {isMySQLLike && mysqlTopology === "replica" && renderConfigSectionCard({ sectionKey: "replica", icon: , children: ( <>
{renderStoredSecretControls({ fieldName: "mysqlReplicaPassword", clearKey: "mysqlReplicaPassword", hasStoredSecret: initialValues?.hasMySQLReplicaPassword, clearLabel: "清除已保存从库密码", description: "当前已保存从库密码。留空表示继续沿用,输入新值表示替换。", })} ), })} {dbType === "mongodb" && renderConfigSectionCard({ sectionKey: "connectionMode", icon: , children: renderChoiceCards({ fieldName: "mongoTopology", value: String(mongoTopology), options: [ { value: "single", label: "单机模式", description: "只连接一个 MongoDB 节点。", }, { value: "replica", label: "副本集 / 多节点", description: "配置副本集名称和多个候选节点。", }, ], }), })} {dbType === "mongodb" && renderConfigSectionCard({ sectionKey: "mongoDiscovery", icon: , children: ( <>
{[ { value: false, label: "标准地址", description: "使用 host:port 直连或副本集节点列表。", }, { value: true, label: "SRV 地址", description: "使用 mongodb+srv,由 DNS 发现目标节点。", }, ].map((option) => { const active = mongoSrv === option.value; return ( ); })}
{mongoSrv && useSSH && ( )} ), })} {dbType === "mongodb" && mongoTopology === "replica" && renderConfigSectionCard({ sectionKey: "replica", icon: , children: ( <>
{renderStoredSecretControls({ fieldName: "mongoReplicaPassword", clearKey: "mongoReplicaPassword", hasStoredSecret: initialValues?.hasMongoReplicaPassword, clearLabel: "清除已保存副本集密码", description: "当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。", })} {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 ? "正常" : "异常"} ), }, ]} /> )} ), })} {dbType === "mongodb" && renderConfigSectionCard({ sectionKey: "mongoPolicy", icon: , children: (
读偏好 (readPreference) {renderChoiceCards({ fieldName: "mongoReadPreference", value: String(mongoReadPreference), minWidth: 130, options: [ { value: "primary", label: "primary", description: "只读主节点。", }, { value: "primaryPreferred", label: "primaryPreferred", description: "主节点优先。", }, { value: "secondary", label: "secondary", description: "只读从节点。", }, { value: "secondaryPreferred", label: "secondaryPreferred", description: "从节点优先。", }, { value: "nearest", label: "nearest", description: "选择最近节点。", }, ], })}
), })} {isRedis && renderConfigSectionCard({ sectionKey: "connectionMode", icon: , children: ( <> {renderChoiceCards({ fieldName: "redisTopology", value: String(redisTopology), options: [ { value: "single", label: "单机模式", description: "只连接一个 Redis 节点。", }, { value: "cluster", label: "集群模式", description: "Redis Cluster,配置多个种子节点。", }, ], })} {redisTopology === "cluster" && ( {redisDbList.map((db) => ( db{db} ))} ), })} {!isFileDb && !isRedis && renderConfigSectionCard({ sectionKey: "credentials", icon: , children: ( <>
{dbType === "mongodb" && (
验证方式 {renderChoiceCards({ fieldName: "mongoAuthMechanism", value: String(mongoAuthMechanism), minWidth: 150, options: [ { value: "", label: "自动协商", description: "交给驱动按服务端能力选择。", }, { value: "NONE", label: "无认证", description: "不发送认证信息。", }, { value: "SCRAM-SHA-1", label: "SCRAM-SHA-1", description: "兼容旧版本 MongoDB。", }, { value: "SCRAM-SHA-256", label: "SCRAM-SHA-256", description: "推荐的 SCRAM 认证。", }, { value: "MONGODB-AWS", label: "MONGODB-AWS", description: "AWS IAM 认证。", }, ], })}
)}
{renderStoredSecretControls({ fieldName: "password", clearKey: "primaryPassword", hasStoredSecret: initialValues?.hasPrimaryPassword, clearLabel: "清除已保存密码", description: "当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。", })} {dbType === "mongodb" && ( 保存密码 )} ), })} {!isFileDb && !isRedis && renderConfigSectionCard({ sectionKey: "databaseScope", icon: , children: ( ), })} )} ); const networkSecuritySection = !isFileDb && !isJVM ? (() => { 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”后,可在这里配置模式、证书与校验策略。
) : (
SSL 模式 {renderChoiceCards({ fieldName: "sslMode", value: String(sslMode), options: [ { value: "preferred", label: "Preferred", description: "优先使用 SSL,失败后按驱动策略处理。", }, { value: "required", label: "Required", description: "必须使用 SSL,并进行证书校验。", }, { value: "skip-verify", label: "Skip Verify", description: "必须使用 SSL,但跳过证书校验。", }, ], })}
{dbType === "dameng" && ( <> )} {sslHintText}
)}
); } if (resolvedNetworkConfig === "ssh") { return (
SSH 隧道
通过跳板机或堡垒机转发数据库连接,适合内网或受限网络环境。
{!useSSH ? (
左侧勾选“SSH 隧道”后,可在这里填写主机、端口、用户名、密码和私钥路径。
) : (
{renderStoredSecretControls({ fieldName: "sshPassword", clearKey: "sshPassword", hasStoredSecret: initialValues?.hasSSHPassword, clearLabel: "清除已保存 SSH 密码", description: "当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。", })}
)}
); } if (resolvedNetworkConfig === "proxy") { return (
代理
适合借助本地代理软件或中间网关转发数据库流量。
{!useProxy ? (
左侧勾选“代理”后,可在这里选择代理类型并填写主机、端口与认证信息。
) : (
代理类型 {renderChoiceCards({ fieldName: "proxyType", value: String(proxyType), minWidth: 150, options: [ { value: "socks5", label: "SOCKS5", description: "常见本地代理和网关代理。", }, { value: "http", label: "HTTP CONNECT", description: "通过 HTTP CONNECT 建立隧道。", }, ], })}
{renderStoredSecretControls({ fieldName: "proxyPassword", clearKey: "proxyPassword", hasStoredSecret: initialValues?.hasProxyPassword, clearLabel: "清除已保存代理密码", description: "当前已保存代理密码。留空表示继续沿用,输入新值表示替换。", })}
)}
); } return (
HTTP 隧道
与代理模式互斥,适合单独指定一条 HTTP CONNECT 隧道路由。
{!useHttpTunnel ? (
左侧勾选“HTTP 隧道”后,可在这里填写隧道目标与认证信息。
) : (
{renderStoredSecretControls({ fieldName: "httpTunnelPassword", clearKey: "httpTunnelPassword", hasStoredSecret: initialValues?.hasHttpTunnelPassword, clearLabel: "清除已保存隧道密码", description: "当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。", })} 与“使用代理”互斥,启用后将通过 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.jvmAllowedModes !== undefined) { const resolvedModes = normalizeEditableJVMModes( changed.jvmAllowedModes, ); const currentPreferredMode = String( form.getFieldValue("jvmPreferredMode") || "", ) .trim() .toLowerCase(); const resolvedPreferredMode = resolvedModes.find((mode) => mode === currentPreferredMode) || resolvedModes[0]; form.setFieldValue("jvmAllowedModes", resolvedModes); form.setFieldValue("jvmPreferredMode", resolvedPreferredMode); form.setFieldValue( "jvmEndpointEnabled", resolvedModes.includes("endpoint"), ); form.setFieldValue( "jvmAgentEnabled", resolvedModes.includes("agent"), ); } 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} } /> )} {currentDriverUpdateReason && ( {currentDriverUpdateReason} } /> )} {(() => { const sectionItems: Array<{ key: "basic" | "network" | "appearance"; title: string; description: string; icon: React.ReactNode; }> = [ { key: "basic", title: "基础信息", description: isJVM ? "JVM 目标、接入模式、JMX、Endpoint、Agent 与诊断增强" : "名称、地址、认证、URI 与数据库范围", icon: , }, ...(!isCustom && !isFileDb && !isJVM ? [ { key: "network" as const, title: "网络与安全", description: "SSL、SSH、代理与高级连接", icon: , }, ] : []), { key: "appearance", title: "外观", description: "自定义图标与颜色", icon: , }, ]; const resolvedSection = sectionItems.some( (item) => item.key === activeConfigSection, ) ? activeConfigSection : sectionItems[0]?.key || "basic"; const effectiveIconType = customIconType || dbType; const effectiveIconColor = customIconColor || getDbDefaultColor(effectiveIconType); const appearanceSection = (
图标
{DB_ICON_TYPES.map((iconKey) => { const isActive = effectiveIconType === iconKey; return ( ); })}
当前:{getDbIconLabel(effectiveIconType)}
颜色
{PRESET_ICON_COLORS.map((presetColor) => { const isActive = effectiveIconColor === presetColor; return (
预览
{getDbIcon(effectiveIconType, effectiveIconColor, 24)} {form.getFieldValue("name") || "连接名称"}
{(customIconType || customIconColor) && ( )}
); const currentSectionContent = resolvedSection === "basic" ? baseInfoSection : resolvedSection === "appearance" ? appearanceSection : 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 testFailureSummary = hasTestError ? summarizeConnectionTestFailureMessage(testResult?.message, "连接失败") : ""; const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking || !!unsupportedJvmModeMessage; return (
{!initialValues && ( )} {testResult ? ( {isTestSuccess ? : } {isTestSuccess ? "连接成功" : "连接失败"} ) : null} {hasTestError && ( {testFailureSummary} )} {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;