Files
MyGoNavi/frontend/src/components/ConnectionModal.tsx
Syngnat 5bbeb7f373 feat(jvm/connection): 优化诊断工作台与连接配置体验
- JVM 诊断工作台改为会话优先布局,未建会话前隐藏命令输入

- 优化命令模板、实时输出、审计历史和能力检查卡片展示

- 连接配置表单引入按数据源分组的卡片化布局

- 补充连接配置布局和 JVM 诊断工作台回归测试
2026-04-26 17:18:10 +08:00

6545 lines
233 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};
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)";
type ConnectionSecretKey =
| "primaryPassword"
| "sshPassword"
| "proxyPassword"
| "httpTunnelPassword"
| "mysqlReplicaPassword"
| "mongoReplicaPassword"
| "opaqueURI"
| "opaqueDSN";
type ConnectionSecretClearState = Record<ConnectionSecretKey, boolean>;
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<string, string[]> = {
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;
message?: string;
};
const normalizeDriverType = (value: string): string => {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "postgresql") return "postgres";
if (normalized === "doris") return "diros";
return normalized;
};
const ConnectionModal: React.FC<{
open: boolean;
onClose: () => void;
initialValues?: SavedConnection | null;
onOpenDriverManager?: () => void;
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
}> = ({ 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<string | undefined>(
undefined,
);
const [customIconColor, setCustomIconColor] = useState<string | undefined>(
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<string[]>([]);
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
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<string, DriverStatusSnapshot>
>({});
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingDbFile, setSelectingDbFile] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(
createEmptyConnectionSecretClearState,
);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(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 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 (
<Form.Item
noStyle
shouldUpdate={(prev, next) => 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 (
<div
style={{
marginBottom: 16,
padding: "10px 12px",
borderRadius: 10,
border: cardBorder,
background: cardBg,
}}
>
<div
style={{
fontSize: 12,
color: overlayTheme.mutedText,
lineHeight: 1.6,
marginBottom: 8,
}}
>
{hasDraftValue
? "已输入新值,保存时会替换当前已保存内容。"
: description}
</div>
<Checkbox
checked={effectiveChecked}
disabled={hasDraftValue}
onChange={(event) => {
const checked = event.target.checked;
setClearSecrets((prev) => ({ ...prev, [clearKey]: checked }));
}}
>
{clearLabel}
</Checkbox>
</div>
);
}}
</Form.Item>
);
};
const renderConnectionModalTitle = (
icon: React.ReactNode,
title: string,
description: string,
) => (
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
display: "grid",
placeItems: "center",
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
}}
>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 16,
fontWeight: 700,
color: overlayTheme.titleText,
}}
>
{title}
</div>
<div
style={{
marginTop: 4,
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}}
>
{description}
</div>
</div>
</div>
);
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,
) => (
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: 14,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<div
style={{
width: 34,
height: 34,
borderRadius: 12,
display: "grid",
placeItems: "center",
flexShrink: 0,
background: darkMode
? "rgba(255,214,102,0.14)"
: "rgba(22,119,255,0.10)",
color: darkMode ? "#ffd666" : "#1677ff",
}}
>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 800,
}}
>
{title}
</div>
<div style={{ ...modalMutedTextStyle, marginTop: 4 }}>
{description}
</div>
</div>
</div>
{badge ? <div style={{ flexShrink: 0 }}>{badge}</div> : null}
</div>
);
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 (
<div
data-connection-config-section={sectionKey}
style={configSectionCardStyle()}
>
{renderJvmSectionHeader(icon, copy.title, copy.description, badge)}
{children}
</div>
);
};
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;
}) => (
<>
<Form.Item name={fieldName} hidden>
<Input {...noAutoCapInputProps} />
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(auto-fit, minmax(${minWidth}px, 1fr))`,
gap: 10,
}}
>
{options.map((option) => {
const active = String(value ?? "") === option.value;
return (
<button
key={option.value || "empty"}
type="button"
aria-pressed={active}
onClick={() =>
onSelect
? onSelect(option.value)
: setChoiceFieldValue(fieldName, option.value)
}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 14,
border: active
? darkMode
? "1px solid rgba(255,214,102,0.42)"
: "1px solid rgba(22,119,255,0.36)"
: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(16,24,40,0.08)",
background: active
? darkMode
? "rgba(255,214,102,0.10)"
: "rgba(22,119,255,0.07)"
: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.03)",
color: darkMode ? "#f5f7ff" : "#162033",
cursor: "pointer",
transition: "all 120ms ease",
boxShadow: active
? darkMode
? "0 0 0 2px rgba(255,214,102,0.10)"
: "0 0 0 2px rgba(22,119,255,0.08)"
: "none",
}}
>
<Space size={8} wrap>
<Text strong>{option.label}</Text>
{active ? <Tag color="blue"></Tag> : null}
</Space>
{option.description ? (
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
{option.description}
</div>
) : null}
</button>
);
})}
</div>
</>
);
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<HTMLElement>,
) => {
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<string, DriverStatusSnapshot>
> => {
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList("", "");
if (!res?.success) {
return result;
}
const data = (res?.data || {}) as any;
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
drivers.forEach((item: any) => {
const type = normalizeDriverType(String(item.type || "").trim());
if (!type) return;
result[type] = {
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
message: String(item.message || "").trim() || undefined,
};
});
return result;
};
const refreshDriverStatus = async () => {
try {
const next = await fetchDriverStatusMap();
setDriverStatusMap(next);
} catch {
setDriverStatusMap({});
} finally {
setDriverStatusLoaded(true);
}
};
const resolveDriverUnavailableReason = async (
type: string,
): Promise<string> => {
const normalized = normalizeDriverType(type);
if (!normalized || normalized === "custom") {
return "";
}
let snapshot = driverStatusMap;
if (!snapshot[normalized]) {
snapshot = await fetchDriverStatusMap();
setDriverStatusMap(snapshot);
}
const status = snapshot[normalized];
if (!status || status.connectable) {
return "";
}
return (
status.message ||
`${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装`
);
};
const promptInstallDriver = (driverType: string, reason: string) => {
const normalized = normalizeDriverType(driverType);
const snapshot = driverStatusMap[normalized];
const driverName = snapshot?.name || normalized || "当前";
Modal.confirm({
title: `${driverName} 驱动不可用`,
content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`,
okText: "去驱动管理安装",
cancelText: "取消",
onOk: () => {
onOpenDriverManager?.();
},
});
};
const parseHostPort = (
raw: string,
defaultPort: number,
): { host: string; port: number } | null => {
const text = String(raw || "").trim();
if (!text) {
return null;
}
if (text.startsWith("[")) {
const closingBracket = text.indexOf("]");
if (closingBracket > 0) {
const host = text.slice(1, closingBracket).trim();
const portText = text
.slice(closingBracket + 1)
.trim()
.replace(/^:/, "");
const parsedPort = Number(portText);
return {
host: host || "localhost",
port:
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
? parsedPort
: defaultPort,
};
}
}
const colonCount = (text.match(/:/g) || []).length;
if (colonCount === 1) {
const splitIndex = text.lastIndexOf(":");
const host = text.slice(0, splitIndex).trim();
const portText = text.slice(splitIndex + 1).trim();
const parsedPort = Number(portText);
return {
host: host || "localhost",
port:
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
? parsedPort
: defaultPort,
};
}
return { host: text, port: defaultPort };
};
const toAddress = (host: string, port: number, defaultPort: number) => {
const safeHost = String(host || "").trim() || "localhost";
const safePort =
Number.isFinite(Number(port)) && Number(port) > 0
? Number(port)
: defaultPort;
return `${safeHost}:${safePort}`;
};
const normalizeAddressList = (
rawList: unknown,
defaultPort: number,
): string[] => {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set<string>();
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<string>();
const result: string[] = [];
list.forEach((entry) => {
const parsed = parseHostPort(String(entry || ""), defaultPort);
if (!parsed?.host) {
return;
}
const host = String(parsed.host).trim();
if (!host || seen.has(host)) {
return;
}
seen.add(host);
result.push(host);
});
return result;
};
const safeDecode = (text: string) => {
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
const normalizeFileDbPath = (rawPath: string): string => {
let pathText = String(rawPath || "").trim();
if (!pathText) {
return "";
}
// 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。
if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) {
pathText = pathText.slice(1);
}
// 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。
const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/);
if (legacyMatch?.[1]) {
return legacyMatch[1];
}
return pathText;
};
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const prefix = `${expectedScheme}://`;
if (!uriText.toLowerCase().startsWith(prefix)) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf("#");
if (hashIndex >= 0) {
rest = rest.slice(0, hashIndex);
}
let queryText = "";
const queryIndex = rest.indexOf("?");
if (queryIndex >= 0) {
queryText = rest.slice(queryIndex + 1);
rest = rest.slice(0, queryIndex);
}
let pathText = "";
const slashIndex = rest.indexOf("/");
if (slashIndex >= 0) {
pathText = rest.slice(slashIndex + 1);
rest = rest.slice(0, slashIndex);
}
let hostText = rest;
let username = "";
let password = "";
const atIndex = rest.lastIndexOf("@");
if (atIndex >= 0) {
const userInfo = rest.slice(0, atIndex);
hostText = rest.slice(atIndex + 1);
const colonIndex = userInfo.indexOf(":");
if (colonIndex >= 0) {
username = safeDecode(userInfo.slice(0, colonIndex));
password = safeDecode(userInfo.slice(colonIndex + 1));
} else {
username = safeDecode(userInfo);
}
}
const hosts = hostText
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return {
username,
password,
hosts,
database: safeDecode(pathText),
params: new URLSearchParams(queryText),
};
};
const parseSingleHostUri = (
uriText: string,
expectedSchemes: string[],
defaultPort: number,
): {
host: string;
port: number;
username: string;
password: string;
database: string;
params: URLSearchParams;
} | null => {
let parsed: ReturnType<typeof parseMultiHostUri> | null = null;
for (const scheme of expectedSchemes) {
parsed = parseMultiHostUri(uriText, scheme);
if (parsed) {
break;
}
}
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${defaultPort}`,
defaultPort,
);
return {
host: primary?.host || "localhost",
port: primary?.port || defaultPort,
username: parsed.username,
password: parsed.password,
database: parsed.database || "",
params: parsed.params,
};
};
const parseUriToValues = (
uriText: string,
type: string,
): Record<string, any> | 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,
};
}
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<string, any> = {
host: parsed.host,
port: parsed.port,
user: parsed.username,
password: parsed.password,
database: parsed.database,
};
if (supportsSSLForType(type)) {
const normalizeBool = (raw: unknown) => {
const text = String(raw ?? "")
.trim()
.toLowerCase();
return (
text === "1" || text === "true" || text === "yes" || text === "on"
);
};
if (
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase"
) {
const sslMode = String(parsed.params.get("sslmode") || "")
.trim()
.toLowerCase();
if (sslMode) {
parsedValues.useSSL = sslMode !== "disable" && sslMode !== "false";
parsedValues.sslMode =
sslMode === "disable" || sslMode === "false"
? "disable"
: "required";
}
} else if (type === "sqlserver") {
const encrypt = String(parsed.params.get("encrypt") || "")
.trim()
.toLowerCase();
const trust = String(
parsed.params.get("TrustServerCertificate") ||
parsed.params.get("trustservercertificate") ||
"",
)
.trim()
.toLowerCase();
const encrypted =
encrypt === "true" ||
encrypt === "mandatory" ||
encrypt === "yes" ||
encrypt === "1" ||
encrypt === "strict";
if (encrypted) {
parsedValues.useSSL = true;
parsedValues.sslMode =
trust === "true" || trust === "1" || trust === "yes"
? "skip-verify"
: "required";
} else if (encrypt) {
parsedValues.useSSL = false;
parsedValues.sslMode = "disable";
}
} else if (type === "clickhouse") {
const secure = String(
parsed.params.get("secure") || parsed.params.get("tls") || "",
)
.trim()
.toLowerCase();
const skipVerify = normalizeBool(parsed.params.get("skip_verify"));
if (secure) {
parsedValues.useSSL = normalizeBool(secure);
parsedValues.sslMode = skipVerify
? "skip-verify"
: parsedValues.useSSL
? "required"
: "disable";
}
} else if (type === "dameng") {
const certPath = String(
parsed.params.get("SSL_CERT_PATH") ||
parsed.params.get("ssl_cert_path") ||
parsed.params.get("sslCertPath") ||
"",
).trim();
const keyPath = String(
parsed.params.get("SSL_KEY_PATH") ||
parsed.params.get("ssl_key_path") ||
parsed.params.get("sslKeyPath") ||
"",
).trim();
parsedValues.sslCertPath = certPath;
parsedValues.sslKeyPath = keyPath;
if (certPath || keyPath) {
parsedValues.useSSL = true;
parsedValues.sslMode = "required";
}
} else if (type === "oracle") {
const ssl = String(
parsed.params.get("SSL") || parsed.params.get("ssl") || "",
)
.trim()
.toLowerCase();
const sslVerify = String(
parsed.params.get("SSL VERIFY") ||
parsed.params.get("ssl verify") ||
parsed.params.get("SSL_VERIFY") ||
parsed.params.get("ssl_verify") ||
"",
)
.trim()
.toLowerCase();
if (ssl) {
parsedValues.useSSL = normalizeBool(ssl);
if (!parsedValues.useSSL) {
parsedValues.sslMode = "disable";
} else {
parsedValues.sslMode = normalizeBool(sslVerify || "true")
? "required"
: "skip-verify";
}
}
} else if (type === "tdengine") {
const protocol = String(parsed.params.get("protocol") || "")
.trim()
.toLowerCase();
const skipVerify = normalizeBool(parsed.params.get("skip_verify"));
if (protocol === "wss") {
parsedValues.useSSL = true;
parsedValues.sslMode = skipVerify ? "skip-verify" : "required";
} else if (protocol === "ws") {
parsedValues.useSSL = false;
parsedValues.sslMode = "disable";
}
}
}
return parsedValues;
}
return null;
};
const createUriAwareRequiredRule =
(messageText: string, validateValue?: (value: unknown) => boolean) =>
({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({
validator(_: unknown, value: unknown) {
const uriText = String(getFieldValue("uri") || "").trim();
const type = String(getFieldValue("type") || dbType)
.trim()
.toLowerCase();
if (uriText && parseUriToValues(uriText, type)) {
return Promise.resolve();
}
const valid = validateValue
? validateValue(value)
: String(value ?? "").trim() !== "";
return valid
? Promise.resolve()
: Promise.reject(new Error(messageText));
},
});
const 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 scheme = type === "postgres" ? "postgresql" : type;
const dbPath = database ? `/${encodeURIComponent(database)}` : "";
const params = new URLSearchParams();
if (supportsSSLForType(type) && values.useSSL) {
const mode = String(values.sslMode || "preferred")
.trim()
.toLowerCase();
if (
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase"
) {
params.set("sslmode", "require");
} else if (type === "sqlserver") {
params.set("encrypt", "true");
params.set(
"TrustServerCertificate",
mode === "skip-verify" || mode === "preferred" ? "true" : "false",
);
} else if (type === "clickhouse") {
params.set("secure", "true");
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
} else if (type === "dameng") {
const certPath = String(values.sslCertPath || "").trim();
const keyPath = String(values.sslKeyPath || "").trim();
if (certPath) params.set("SSL_CERT_PATH", certPath);
if (keyPath) params.set("SSL_KEY_PATH", keyPath);
} else if (type === "oracle") {
params.set("SSL", "TRUE");
params.set("SSL VERIFY", mode === "required" ? "TRUE" : "FALSE");
} else if (type === "tdengine") {
params.set("protocol", "wss");
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
}
} else if (supportsSSLForType(type)) {
if (
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase"
) {
params.set("sslmode", "disable");
} else if (type === "sqlserver") {
params.set("encrypt", "disable");
params.set("TrustServerCertificate", "true");
} else if (type === "tdengine") {
params.set("protocol", "ws");
}
}
const query = params.toString();
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ""}`;
};
const handleGenerateURI = () => {
try {
const values = form.getFieldsValue(true);
const uri = buildUriFromValues(values);
form.setFieldValue("uri", uri);
setUriFeedback({ type: "success", message: "URI 已生成" });
} catch {
setUriFeedback({ type: "error", message: "生成 URI 失败" });
}
};
const handleParseURI = () => {
try {
const uriText = String(form.getFieldValue("uri") || "").trim();
const type = String(form.getFieldValue("type") || dbType)
.trim()
.toLowerCase();
if (!uriText) {
setUriFeedback({ type: "warning", message: "请先输入 URI" });
return;
}
const parsedValues = parseUriToValues(uriText, type);
if (!parsedValues) {
setUriFeedback({
type: "error",
message: "当前 URI 与数据源类型不匹配,或 URI 格式不支持",
});
return;
}
form.setFieldsValue({ ...parsedValues, uri: uriText });
if (testResult) {
setTestResult(null);
}
setUriFeedback({ type: "success", message: "已根据 URI 回填连接参数" });
} catch {
setUriFeedback({
type: "error",
message: "URI 解析失败,请检查格式后重试",
});
}
};
const handleCopyURI = async () => {
let uriText = String(form.getFieldValue("uri") || "").trim();
if (!uriText) {
const values = form.getFieldsValue(true);
uriText = buildUriFromValues(values);
form.setFieldValue("uri", uriText);
}
if (!uriText) {
setUriFeedback({ type: "warning", message: "没有可复制的 URI" });
return;
}
try {
await navigator.clipboard.writeText(uriText);
setUriFeedback({ type: "success", message: "URI 已复制" });
} catch {
setUriFeedback({ type: "error", message: "复制失败" });
}
};
const handleSelectSSHKeyFile = async () => {
if (selectingSSHKey) {
return;
}
try {
setSelectingSSHKey(true);
const currentPath = String(form.getFieldValue("sshKeyPath") || "").trim();
const res = await SelectSSHKeyFile(currentPath);
if (res?.success) {
const data = res.data || {};
const selectedPath =
typeof data === "string" ? data : String(data.path || "").trim();
if (selectedPath) {
form.setFieldValue("sshKeyPath", selectedPath);
}
} else if (res?.message !== "已取消") {
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 || "",
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,
);
if (unavailableReason) {
message.warning(unavailableReason);
promptInstallDriver(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 <T,>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string,
): Promise<T> => {
let timer: number | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, 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,
);
if (unavailableReason) {
applyTestFailureFeedback(
resolveConnectionTestFailureFeedback({
kind: "driver_unavailable",
reason: unavailableReason,
fallback: "驱动未安装启用",
}),
);
promptInstallDriver(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<string, any>) || {};
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<ConnectionConfig> => {
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 (isEmptyField((mergedValues as any)[key])) {
(mergedValues as any)[key] = value;
}
});
}
const type = String(mergedValues.type || "").toLowerCase();
const defaultPort = getDefaultPortByType(type);
const isFileDbType = isFileDatabaseType(type);
const sslCapableType = supportsSSLForType(type);
// 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<ConnectionConfig["proxy"]> =
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<ConnectionConfig["httpTunnel"]> =
effectiveUseHttpTunnel
? {
host: String(mergedValues.httpTunnelHost || "").trim(),
port: Number(mergedValues.httpTunnelPort || 8080),
user: String(mergedValues.httpTunnelUser || "").trim(),
password: mergedValues.httpTunnelPassword || "",
}
: {
host: "",
port: 8080,
user: "",
password: "",
};
if (effectiveUseHttpTunnel) {
if (!httpTunnelConfig.host) {
throw new Error("HTTP 隧道主机不能为空");
}
if (
!Number.isFinite(httpTunnelConfig.port) ||
httpTunnelConfig.port <= 0 ||
httpTunnelConfig.port > 65535
) {
throw new Error("HTTP 隧道端口必须在 1-65535 之间");
}
}
const keepPassword = !forPersist || savePassword;
return {
type: mergedValues.type,
host: primaryHost,
port: Number(primaryPort || 0),
user: mergedValues.user || "",
password: keepPassword ? mergedValues.password || "" : "",
savePassword: savePassword,
database: mergedValues.database || "",
useSSL: effectiveUseSSL,
sslMode: effectiveUseSSL ? sslMode : "disable",
sslCertPath: sslCertPath,
sslKeyPath: sslKeyPath,
useSSH: !!mergedValues.useSSH,
ssh: sshConfig,
useProxy: effectiveUseProxy,
proxy: proxyConfig,
useHttpTunnel: effectiveUseHttpTunnel,
httpTunnel: httpTunnelConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
redisDB: Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0,
uri: String(mergedValues.uri || "").trim(),
hosts: hosts,
topology: topology,
mysqlReplicaUser: mysqlReplicaUser,
mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "",
replicaSet: replicaSet,
authSource: authSource,
readPreference: readPreference,
mongoSrv: mongoSrvEnabled,
mongoAuthMechanism: mongoAuthMechanism,
mongoReplicaUser: mongoReplicaUser,
mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "",
};
};
const handleTypeSelect = (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 });
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 = normalizeDriverType(dbType);
const currentDriverSnapshot = driverStatusMap[currentDriverType];
const currentDriverUnavailableReason =
currentDriverType !== "custom" &&
currentDriverSnapshot &&
!currentDriverSnapshot.connectable
? currentDriverSnapshot.message ||
`${currentDriverSnapshot.name || dbType} 驱动未安装启用`
: "";
const driverStatusChecking =
currentDriverType !== "custom" && !driverStatusLoaded && step === 2;
const dbTypeGroups = [
{
label: "关系型数据库",
items: [
{
key: "mysql",
name: "MySQL",
icon: 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 = () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
height: "100%",
}}
>
<div style={{ ...modalInnerSectionStyle, paddingBottom: 12 }}>
<div
style={{
marginBottom: 12,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
</div>
<div style={modalMutedTextStyle}>
</div>
</div>
{typeSelectWarning && (
<Alert
type="warning"
showIcon
closable
message={`${typeSelectWarning.driverName} 驱动未启用`}
description={
<Space size={8}>
<span>{typeSelectWarning.reason}</span>
<Button
type="link"
size="small"
onClick={() => onOpenDriverManager?.()}
>
</Button>
</Space>
}
onClose={() => setTypeSelectWarning(null)}
/>
)}
<div
style={{
...modalInnerSectionStyle,
display: "flex",
flex: 1,
minHeight: 0,
padding: 12,
}}
>
{/* 左侧分类导航 */}
<div
style={{
width: 148,
borderRight: `1px solid ${step1SidebarDividerColor}`,
paddingRight: 10,
flexShrink: 0,
overflowY: "auto",
}}
>
{dbTypeGroups.map((group, idx) => (
<div
key={group.label}
onClick={() => 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}
</div>
))}
</div>
{/* 右侧数据源卡片 */}
<div
style={{
flex: 1,
minHeight: 0,
paddingLeft: 18,
overflowY: "auto",
overflowX: "hidden",
}}
>
<Row gutter={[14, 14]}>
{dbTypeGroups[activeGroup]?.items.map((item) => (
<Col span={12} key={item.key}>
<Card
hoverable
onClick={() => {
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%",
},
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: 14,
display: "grid",
placeItems: "center",
flexShrink: 0,
background: darkMode
? "rgba(255,255,255,0.05)"
: "rgba(22,119,255,0.08)",
}}
>
{item.icon}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<Text
strong
style={{
fontSize: 14,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
display: "block",
}}
>
{item.name}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{getDbTypeHint(item.key)}
</Text>
</div>
</Card>
</Col>
))}
</Row>
</div>
</div>
</div>
);
const renderStep2 = () => {
const baseInfoSection = (
<div style={modalInnerSectionStyle}>
<div
style={{
marginBottom: 12,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 16 }}>
</div>
<div style={{ display: "grid", gap: 16 }}>
{renderConfigSectionCard({
sectionKey: "identity",
icon: <ApiOutlined />,
badge: (
<Tag>
{getConnectionConfigLayoutKindLabel(connectionConfigLayout.kind)}
</Tag>
),
children: (
<Form.Item name="name" label="连接名称" style={{ marginBottom: 0 }}>
<Input
{...noAutoCapInputProps}
placeholder={
isJVM
? "例如:本地 JVM / 订单服务 JVM"
: "例如:本地测试库"
}
/>
</Form.Item>
),
})}
{!isCustom &&
!isJVM &&
renderConfigSectionCard({
sectionKey: "uri",
icon: <LinkOutlined />,
children: (
<>
<Form.Item
name="uri"
label="连接 URI可复制粘贴"
help="支持从参数生成、复制到剪贴板,或粘贴后一键解析回填参数"
>
<Input.TextArea
{...noAutoCapInputProps}
rows={3}
placeholder={getUriPlaceholder()}
/>
</Form.Item>
<Space
size={8}
style={{ marginBottom: uriFeedback ? 12 : 16 }}
wrap
>
<Button onClick={handleGenerateURI}> URI</Button>
<Button onClick={handleParseURI}> URI </Button>
<Button onClick={handleCopyURI}> URI</Button>
</Space>
{uriFeedback && (
<Alert
showIcon
closable
type={uriFeedback.type}
message={uriFeedback.message}
onClose={() => setUriFeedback(null)}
style={{ marginBottom: 16 }}
/>
)}
{renderStoredSecretControls({
fieldName: "uri",
clearKey: "opaqueURI",
hasStoredSecret: initialValues?.hasOpaqueURI,
clearLabel: "清除已保存 URI",
description:
"当前已保存连接 URI。留空表示继续沿用输入新值表示替换。",
})}
</>
),
})}
{isCustom ? (
<>
{renderConfigSectionCard({
sectionKey: "customDriver",
icon: <CodeOutlined />,
children: (
<Form.Item
name="driver"
label="驱动名称 (Driver Name)"
rules={[{ required: true, message: "请输入驱动名称" }]}
help={CUSTOM_CONNECTION_DRIVER_HELP}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如: mysql, postgres"
/>
</Form.Item>
),
})}
{renderConfigSectionCard({
sectionKey: "customDsn",
icon: <FileTextOutlined />,
children: (
<>
<Form.Item
name="dsn"
label="连接字符串 (DSN)"
rules={[createCustomDsnRule()]}
>
<Input.TextArea
{...noAutoCapInputProps}
rows={4}
placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8"
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: "dsn",
clearKey: "opaqueDSN",
hasStoredSecret: initialValues?.hasOpaqueDSN,
clearLabel: "清除已保存 DSN",
description:
"当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。",
})}
</>
),
})}
</>
) : isJVM ? (
<>
{unsupportedJvmModeMessage && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="检测到未支持的 JVM 模式"
description={unsupportedJvmModeMessage}
/>
)}
<div style={{ display: "grid", gap: 16 }}>
<div style={jvmSectionCardStyle()}>
{renderJvmSectionHeader(
<GatewayOutlined />,
"目标 JVM",
"定义连接树中的主机入口和基础运行环境。",
)}
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
gap: 16,
alignItems: "start",
}}
>
<Form.Item
name="host"
label="主机地址"
rules={[{ required: true, message: "请输入 JVM 主机地址" }]}
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder="localhost" />
</Form.Item>
<Form.Item
name="port"
label="主端口"
rules={[{ required: true, message: "请输入 JVM 端口号" }]}
style={{ marginBottom: 0 }}
>
<InputNumber style={{ width: "100%" }} min={1} max={65535} />
</Form.Item>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 16,
marginTop: 16,
}}
>
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{renderChoiceCards({
fieldName: "jvmEnvironment",
value: String(jvmEnvironment),
minWidth: 120,
options: [
{
value: "dev",
label: "开发 / 测试",
description: "本地或测试环境。",
},
{
value: "uat",
label: "预发 / 验收",
description: "上线前验证环境。",
},
{
value: "prod",
label: "生产",
description: "生产 JVM默认更谨慎。",
},
],
})}
</div>
<Form.Item
name="timeout"
label="连接超时(秒)"
rules={[
{
type: "number",
min: 1,
max: 300,
message: "超时时间范围: 1-300 秒",
},
]}
style={{ marginBottom: 0 }}
>
<InputNumber
style={{ width: "100%" }}
min={1}
max={300}
placeholder="30"
/>
</Form.Item>
<Form.Item
name="jvmReadOnly"
label="安全策略"
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Checkbox></Checkbox>
</Form.Item>
</div>
</div>
<div style={jvmSectionCardStyle()}>
{renderJvmSectionHeader(
<ClusterOutlined />,
"接入模式",
"通过卡片选择允许使用的 JVM 通道;已启用卡片再次点击会设为首选。",
)}
<Form.Item
name="jvmAllowedModes"
hidden
rules={[
{
required: true,
message: "请至少选择一种 JVM 接入模式",
},
]}
>
<Select mode="multiple" />
</Form.Item>
<Form.Item
name="jvmPreferredMode"
hidden
rules={[
{
required: true,
message: "请选择首选 JVM 接入模式",
},
]}
>
<Input {...noAutoCapInputProps} />
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 14,
}}
>
{JVM_EDITABLE_MODES.map((mode) => {
const meta = resolveJVMModeMeta(mode);
const enabled = normalizedJvmAllowedModes.includes(mode);
const preferred = jvmPreferredMode === mode;
return (
<div
key={mode}
role="button"
tabIndex={0}
onClick={() => 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",
}}
>
<Space size={8} wrap>
<Tag color={enabled ? "blue" : "default"}>
{meta.label}
</Tag>
{preferred ? <Tag color="green"></Tag> : null}
{!enabled ? <Tag></Tag> : null}
</Space>
<div style={{ ...modalMutedTextStyle, marginTop: 8 }}>
{mode === "jmx"
? "标准 MBean 与线程、内存、类加载等运行时指标。"
: mode === "endpoint"
? "通过服务端管理接口读取 JVM 资源与配置。"
: "通过 GoNavi Java Agent 提供更完整的增强能力。"}
</div>
<Button
size="small"
type={enabled ? "default" : "primary"}
disabled={enabled && normalizedJvmAllowedModes.length <= 1}
onClick={(event) => handleJvmModeToggle(mode, event)}
style={{ marginTop: 12, borderRadius: 999 }}
>
{enabled ? "停用" : "启用并设为首选"}
</Button>
</div>
);
})}
</div>
<div style={{ ...modalMutedTextStyle, marginTop: 12 }}>
{resolveJVMModeMeta(String(jvmPreferredMode || "jmx")).label}
</div>
</div>
<div style={jvmSectionCardStyle()}>
{renderJvmSectionHeader(
<ApiOutlined />,
"JMX",
"标准 JVM 管理通道,可覆盖主机/端口并配置认证。",
<Tag color={normalizedJvmAllowedModes.includes("jmx") ? "green" : "default"}>
{normalizedJvmAllowedModes.includes("jmx") ? "已启用" : "未启用"}
</Tag>,
)}
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
gap: 16,
}}
>
<Form.Item
name="jvmJmxHost"
label="JMX 主机覆盖(可选)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("jmx")}
placeholder="留空沿用主机地址"
/>
</Form.Item>
<Form.Item
name="jvmJmxPort"
label="JMX 端口"
style={{ marginBottom: 0 }}
>
<InputNumber
style={{ width: "100%" }}
min={1}
max={65535}
disabled={!normalizedJvmAllowedModes.includes("jmx")}
placeholder="沿用主端口"
/>
</Form.Item>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
marginTop: 16,
}}
>
<Form.Item
name="jvmJmxUsername"
label="JMX 用户名(可选)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("jmx")}
placeholder="未开启认证可留空"
/>
</Form.Item>
<Form.Item
name="jvmJmxPassword"
label="JMX 密码(可选)"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("jmx")}
placeholder="未开启认证可留空"
/>
</Form.Item>
</div>
</div>
<div style={jvmSectionCardStyle()}>
{renderJvmSectionHeader(
<CodeOutlined />,
"Endpoint",
"连接应用暴露的 JVM 管理端点,适合已有运维 API 的服务。",
<Tag
color={
normalizedJvmAllowedModes.includes("endpoint")
? "green"
: "default"
}
>
{normalizedJvmAllowedModes.includes("endpoint")
? "已启用"
: "未启用"}
</Tag>,
)}
<Form.Item
name="jvmEndpointBaseUrl"
label="Endpoint 地址"
rules={[
{
required: jvmPreferredMode === "endpoint",
message: "启用 Endpoint 模式时请输入 Endpoint 地址",
},
]}
help="例如 Spring Boot Actuator 或自定义管理接口地址。"
>
<Input
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("endpoint")}
placeholder="例如https://orders.internal/manage/jvm"
/>
</Form.Item>
<Form.Item
name="jvmEndpointApiKey"
label="Endpoint API Key可选"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("endpoint")}
placeholder="端点受 Token 保护时填写"
/>
</Form.Item>
</div>
<div style={jvmSectionCardStyle()}>
{renderJvmSectionHeader(
<ThunderboltOutlined />,
"Agent",
"连接 GoNavi Java Agent 管理端口,用于增强采集和诊断链路。",
<Tag color={normalizedJvmAllowedModes.includes("agent") ? "green" : "default"}>
{normalizedJvmAllowedModes.includes("agent") ? "已启用" : "未启用"}
</Tag>,
)}
<Form.Item
name="jvmAgentBaseUrl"
label="Agent 地址"
rules={[
{
required: jvmPreferredMode === "agent",
message: "启用 Agent 模式时请输入 Agent 地址",
},
]}
help="目标 Java 服务需要以 -javaagent 方式启动 GoNavi Agent。"
>
<Input
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("agent")}
placeholder="例如http://127.0.0.1:19090/gonavi/agent/jvm"
/>
</Form.Item>
<Form.Item
name="jvmAgentApiKey"
label="Agent API Key可选"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
disabled={!normalizedJvmAllowedModes.includes("agent")}
placeholder="Agent 启用 Token 校验时填写"
/>
</Form.Item>
</div>
<div style={jvmSectionCardStyle()}>
{renderJvmSectionHeader(
<SafetyCertificateOutlined />,
"诊断增强",
"开启后可创建 JVM 诊断会话并执行受控 Arthas/诊断命令。",
<Form.Item
name="jvmDiagnosticEnabled"
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>,
)}
{jvmDiagnosticEnabled ? (
<>
<div
style={{
display: "grid",
gridTemplateColumns: "220px minmax(0, 1fr)",
gap: 16,
}}
>
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{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。",
},
],
})}
</div>
<Form.Item
name="jvmDiagnosticBaseUrl"
label={
jvmDiagnosticTransport === "arthas-tunnel"
? "Arthas Tunnel 地址"
: "诊断 Bridge 地址"
}
rules={[
{
required: true,
message:
jvmDiagnosticTransport === "arthas-tunnel"
? "请输入 Arthas Tunnel Server 地址"
: "请输入诊断 Bridge 地址",
},
]}
help={
jvmDiagnosticTransport === "arthas-tunnel"
? "例如http://127.0.0.1:7777支持反向代理后的访问前缀。"
: "例如http://127.0.0.1:19091/gonavi/diag"
}
>
<Input
{...noAutoCapInputProps}
placeholder={
jvmDiagnosticTransport === "arthas-tunnel"
? "http://127.0.0.1:7777"
: "http://127.0.0.1:19091/gonavi/diag"
}
/>
</Form.Item>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 220px",
gap: 16,
}}
>
<Form.Item
name="jvmDiagnosticTargetId"
label={
jvmDiagnosticTransport === "arthas-tunnel"
? "目标实例标识AgentId"
: "目标实例标识"
}
rules={
jvmDiagnosticTransport === "arthas-tunnel"
? [
{
required: true,
message:
"Arthas Tunnel 模式必须填写目标实例标识",
},
]
: undefined
}
help={
jvmDiagnosticTransport === "arthas-tunnel"
? "填写 Arthas Tunnel 中目标 JVM 的 agentId。"
: "可选,用于在桥接端区分具体 JVM 实例。"
}
>
<Input
{...noAutoCapInputProps}
placeholder={
jvmDiagnosticTransport === "arthas-tunnel"
? "例如orders-app_A1B2C3D4E5"
: "例如orders-prod-01"
}
/>
</Form.Item>
<Form.Item
name="jvmDiagnosticTimeoutSeconds"
label="诊断超时(秒)"
rules={[
{
type: "number",
min: 1,
max: 300,
message: "诊断超时时间范围: 1-300 秒",
},
]}
>
<InputNumber style={{ width: "100%" }} min={1} max={300} />
</Form.Item>
</div>
<Form.Item
name="jvmDiagnosticApiKey"
label="诊断 API Key可选"
>
<Input.Password
{...noAutoCapInputProps}
placeholder="诊断桥接端启用 Token 校验时填写"
/>
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(180px, 1fr))",
gap: 10,
}}
>
{[
{
name: "jvmDiagnosticAllowObserveCommands",
label: "观察类命令",
description: "thread、dashboard、jvm 等只读排查命令。",
},
{
name: "jvmDiagnosticAllowTraceCommands",
label: "跟踪类命令",
description: "trace、watch 等对目标有额外开销的命令。",
},
{
name: "jvmDiagnosticAllowMutatingCommands",
label: "高风险命令",
description: "可能改变运行态或造成明显性能影响的命令。",
},
].map((item) => (
<div
key={item.name}
style={{
padding: 12,
borderRadius: 14,
background: darkMode
? "rgba(255,255,255,0.04)"
: "rgba(16,24,40,0.04)",
}}
>
<Form.Item
name={item.name}
valuePropName="checked"
style={{ marginBottom: 6 }}
>
<Checkbox>{item.label}</Checkbox>
</Form.Item>
<div style={modalMutedTextStyle}>
{item.description}
</div>
</div>
))}
</div>
</>
) : (
<div
style={{
...modalMutedTextStyle,
padding: "10px 12px",
borderRadius: 12,
background: darkMode
? "rgba(255,255,255,0.04)"
: "rgba(16,24,40,0.04)",
}}
>
JVM
</div>
)}
</div>
</div>
</>
) : (
<>
{renderConfigSectionCard({
sectionKey: isFileDb ? "fileTarget" : "target",
icon: isFileDb ? <FileTextOutlined /> : <GatewayOutlined />,
children: (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
gap: 16,
alignItems: "start",
}}
>
<Form.Item
name="host"
label={
isFileDb ? "文件路径 (绝对路径)" : "主机地址 (Host)"
}
rules={[createUriAwareRequiredRule("请输入地址/路径")]}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={
isFileDb
? dbType === "duckdb"
? "/path/to/db.duckdb"
: "/path/to/db.sqlite"
: "localhost"
}
/>
</Form.Item>
{isFileDb ? (
<Form.Item label=" " style={{ marginBottom: 0 }}>
<Button
style={{ width: "100%" }}
onClick={handleSelectDatabaseFile}
loading={selectingDbFile}
>
...
</Button>
</Form.Item>
) : (
<Form.Item
name="port"
label="端口 (Port)"
rules={[
createUriAwareRequiredRule(
"请输入端口号",
(value) => Number(value) > 0,
),
]}
style={{ marginBottom: 0 }}
>
<InputNumber style={{ width: "100%" }} />
</Form.Item>
)}
</div>
),
})}
{(dbType === "postgres" ||
dbType === "kingbase" ||
dbType === "highgo" ||
dbType === "vastbase") &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="database"
label="默认连接数据库(可选)"
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder="例如appdb" />
</Form.Item>
),
})}
{dbType === "oracle" &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="database"
label="服务名 (Service Name)"
rules={[
createUriAwareRequiredRule(
"请输入 Oracle 服务名(例如 ORCLPDB1",
),
]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如ORCLPDB1"
/>
</Form.Item>
),
})}
{isMySQLLike &&
renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: renderChoiceCards({
fieldName: "mysqlTopology",
value: String(mysqlTopology),
options: [
{
value: "single",
label: "单机模式",
description: "只连接一个主库地址,适合本地和单实例。",
},
{
value: "replica",
label: "主从模式",
description: "主库优先,可配置从库地址用于切换。",
},
],
}),
})}
{isMySQLLike &&
mysqlTopology === "replica" &&
renderConfigSectionCard({
sectionKey: "replica",
icon: <ClusterOutlined />,
children: (
<>
<Form.Item
name="mysqlReplicaHosts"
label="从库地址列表"
help="可输入多个从库地址格式host:port回车确认"
>
<Select
mode="tags"
placeholder="例如10.10.0.12:3306、10.10.0.13:3306"
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="mysqlReplicaUser"
label="从库用户名(可选)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="留空沿用主库用户名"
/>
</Form.Item>
<Form.Item
name="mysqlReplicaPassword"
label="从库密码(可选)"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasMySQLReplicaPassword,
emptyPlaceholder: "留空沿用主库密码",
retainedLabel: "已保存从库密码",
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: "mysqlReplicaPassword",
clearKey: "mysqlReplicaPassword",
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
clearLabel: "清除已保存从库密码",
description:
"当前已保存从库密码。留空表示继续沿用,输入新值表示替换。",
})}
</>
),
})}
{dbType === "mongodb" &&
renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: renderChoiceCards({
fieldName: "mongoTopology",
value: String(mongoTopology),
options: [
{
value: "single",
label: "单机模式",
description: "只连接一个 MongoDB 节点。",
},
{
value: "replica",
label: "副本集 / 多节点",
description: "配置副本集名称和多个候选节点。",
},
],
}),
})}
{dbType === "mongodb" &&
renderConfigSectionCard({
sectionKey: "mongoDiscovery",
icon: <ApiOutlined />,
children: (
<>
<Form.Item name="mongoSrv" hidden valuePropName="checked">
<Checkbox />
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(180px, 1fr))",
gap: 10,
}}
>
{[
{
value: false,
label: "标准地址",
description: "使用 host:port 直连或副本集节点列表。",
},
{
value: true,
label: "SRV 地址",
description:
"使用 mongodb+srv由 DNS 发现目标节点。",
},
].map((option) => {
const active = mongoSrv === option.value;
return (
<button
key={String(option.value)}
type="button"
aria-pressed={active}
onClick={() =>
setChoiceFieldValue("mongoSrv", option.value)
}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 14,
border: active
? darkMode
? "1px solid rgba(255,214,102,0.42)"
: "1px solid rgba(22,119,255,0.36)"
: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(16,24,40,0.08)",
background: active
? darkMode
? "rgba(255,214,102,0.10)"
: "rgba(22,119,255,0.07)"
: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.03)",
color: darkMode ? "#f5f7ff" : "#162033",
cursor: "pointer",
}}
>
<Space size={8} wrap>
<Text strong>{option.label}</Text>
{active ? <Tag color="blue"></Tag> : null}
</Space>
<div
style={{
...modalMutedTextStyle,
marginTop: 6,
}}
>
{option.description}
</div>
</button>
);
})}
</div>
{mongoSrv && useSSH && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"
/>
)}
</>
),
})}
{dbType === "mongodb" &&
mongoTopology === "replica" &&
renderConfigSectionCard({
sectionKey: "replica",
icon: <ClusterOutlined />,
children: (
<>
<Form.Item
name="mongoHosts"
label={
mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"
}
help={
mongoSrv
? "可输入多个候选主机名格式host若留空则仅使用上方主机。"
: "可输入多个节点地址格式host:port回车确认"
}
>
<Select
mode="tags"
placeholder={
mongoSrv
? "例如cluster-a.example.com、cluster-b.example.com"
: "例如10.10.0.12:27017、10.10.0.13:27017"
}
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="mongoReplicaSet"
label="副本集名称(可选)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如rs0"
/>
</Form.Item>
<Form.Item
name="mongoReplicaUser"
label="副本集用户名(可选)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="留空沿用主用户名"
/>
</Form.Item>
</div>
<Form.Item
name="mongoReplicaPassword"
label="副本集密码(可选)"
style={{ marginTop: 16, marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasMongoReplicaPassword,
emptyPlaceholder: "留空沿用主密码",
retainedLabel: "已保存副本集密码",
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: "mongoReplicaPassword",
clearKey: "mongoReplicaPassword",
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
clearLabel: "清除已保存副本集密码",
description:
"当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。",
})}
<Space
size={8}
style={{ marginTop: 12, marginBottom: 12 }}
>
<Button
onClick={handleDiscoverMongoMembers}
loading={discoveringMembers}
>
</Button>
</Space>
{mongoMembers.length > 0 && (
<Table
size="small"
rowKey={(record) => 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,
) => (
<Tag
color={record.isSelf ? "blue" : "default"}
>
{value || "UNKNOWN"}
</Tag>
),
},
{
title: "健康",
dataIndex: "healthy",
width: "20%",
render: (value: boolean) => (
<Tag color={value ? "success" : "error"}>
{value ? "正常" : "异常"}
</Tag>
),
},
]}
/>
)}
</>
),
})}
{dbType === "mongodb" &&
renderConfigSectionCard({
sectionKey: "mongoPolicy",
icon: <ThunderboltOutlined />,
children: (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="mongoAuthSource"
label="认证库 (authSource)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="默认使用 database 或 admin"
/>
</Form.Item>
<div style={{ display: "grid", gap: 8 }}>
<Text strong> (readPreference)</Text>
{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: "选择最近节点。",
},
],
})}
</div>
</div>
),
})}
{isRedis &&
renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: (
<>
{renderChoiceCards({
fieldName: "redisTopology",
value: String(redisTopology),
options: [
{
value: "single",
label: "单机模式",
description: "只连接一个 Redis 节点。",
},
{
value: "cluster",
label: "集群模式",
description: "Redis Cluster配置多个种子节点。",
},
],
})}
{redisTopology === "cluster" && (
<Form.Item
name="redisHosts"
label="集群附加节点地址"
help="主节点使用上方主机地址这里填写其他种子节点格式host:port"
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
mode="tags"
placeholder="例如10.10.0.12:6379、10.10.0.13:6379"
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
)}
</>
),
})}
{isRedis &&
renderConfigSectionCard({
sectionKey: "credentials",
icon: <SafetyCertificateOutlined />,
children: (
<>
<Form.Item name="password" label="密码 (可选)">
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder:
"Redis 密码(如果设置了 requirepass",
retainedLabel: "已保存 Redis 密码",
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: "password",
clearKey: "primaryPassword",
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: "清除已保存密码",
description:
"当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。",
})}
</>
),
})}
{isRedis &&
renderConfigSectionCard({
sectionKey: "databaseScope",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
help="连接测试成功后可选择"
style={{ marginBottom: 0 }}
>
<Select
mode="multiple"
placeholder="选择显示的数据库 (0-15)"
allowClear
>
{redisDbList.map((db) => (
<Select.Option key={db} value={db}>
db{db}
</Select.Option>
))}
</Select>
</Form.Item>
),
})}
{!isFileDb &&
!isRedis &&
renderConfigSectionCard({
sectionKey: "credentials",
icon: <SafetyCertificateOutlined />,
children: (
<>
<div
style={{
display: "grid",
gridTemplateColumns:
dbType === "mongodb"
? "minmax(0, 1fr) minmax(0, 1fr) 180px"
: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="user"
label="用户名"
rules={
dbType === "mongodb"
? []
: [createUriAwareRequiredRule("请输入用户名")]
}
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} />
</Form.Item>
<Form.Item
name="password"
label="密码"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasPrimaryPassword,
emptyPlaceholder: "密码",
retainedLabel: "已保存密码",
})}
/>
</Form.Item>
{dbType === "mongodb" && (
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{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 认证。",
},
],
})}
</div>
)}
</div>
{renderStoredSecretControls({
fieldName: "password",
clearKey: "primaryPassword",
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: "清除已保存密码",
description:
"当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。",
})}
{dbType === "mongodb" && (
<Form.Item
name="savePassword"
valuePropName="checked"
style={{ marginTop: 12, marginBottom: 0 }}
>
<Checkbox></Checkbox>
</Form.Item>
)}
</>
),
})}
{!isFileDb &&
!isRedis &&
renderConfigSectionCard({
sectionKey: "databaseScope",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="includeDatabases"
label="显示数据库 (留空显示全部)"
help="连接测试成功后可选择"
style={{ marginBottom: 0 }}
>
<Select
mode="multiple"
placeholder="选择显示的数据库"
allowClear
>
{dbList.map((db) => (
<Select.Option key={db} value={db}>
{db}
</Select.Option>
))}
</Select>
</Form.Item>
),
})}
</>
)}
</div>
</div>
);
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 (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div
style={{
marginBottom: 8,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
SSL/TLS
</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}>
访
</div>
{!useSSL ? (
<div
style={{
...modalMutedTextStyle,
padding: "10px 12px",
borderRadius: 12,
background: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.04)",
}}
>
SSL/TLS
</div>
) : (
<div style={tunnelSectionStyle}>
<div style={{ display: "grid", gap: 8, marginBottom: 14 }}>
<Text strong>SSL </Text>
{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但跳过证书校验。",
},
],
})}
</div>
{dbType === "dameng" && (
<>
<Form.Item
name="sslCertPath"
label="客户端证书路径 (SSL_CERT_PATH)"
rules={[
{
required: true,
message: "达梦 SSL 需要证书路径",
},
]}
style={{ marginBottom: 8 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如: C:\certs\client-cert.pem"
/>
</Form.Item>
<Form.Item
name="sslKeyPath"
label="客户端私钥路径 (SSL_KEY_PATH)"
rules={[
{
required: true,
message: "达梦 SSL 需要私钥路径",
},
]}
style={{ marginBottom: 8 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如: C:\certs\client-key.pem"
/>
</Form.Item>
</>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{sslHintText}
</Text>
</div>
)}
</div>
);
}
if (resolvedNetworkConfig === "ssh") {
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div
style={{
marginBottom: 8,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
SSH
</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}>
</div>
{!useSSH ? (
<div
style={{
...modalMutedTextStyle,
padding: "10px 12px",
borderRadius: 12,
background: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.04)",
}}
>
SSH
</div>
) : (
<div style={tunnelSectionStyle}>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
gap: 16,
}}
>
<Form.Item
name="sshHost"
label="SSH 主机 (域名或IP)"
rules={[
{ required: useSSH, message: "请输入SSH主机" },
]}
style={{ flex: 1 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如: ssh.example.com 或 192.168.1.100"
/>
</Form.Item>
<Form.Item
name="sshPort"
label="端口"
rules={[
{ required: useSSH, message: "请输入SSH端口" },
]}
style={{ width: 100 }}
>
<InputNumber style={{ width: "100%" }} />
</Form.Item>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="sshUser"
label="SSH 用户"
rules={[
{ required: useSSH, message: "请输入SSH用户" },
]}
style={{ flex: 1 }}
>
<Input
{...noAutoCapInputProps}
placeholder="root"
/>
</Form.Item>
<Form.Item
name="sshPassword"
label="SSH 密码"
style={{ flex: 1 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasSSHPassword,
emptyPlaceholder: "密码",
retainedLabel: "已保存 SSH 密码",
})}
/>
</Form.Item>
</div>
<Form.Item
label="私钥路径 (可选)"
help="例如: /Users/name/.ssh/id_rsa"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="sshKeyPath" noStyle>
<Input
{...noAutoCapInputProps}
placeholder="绝对路径"
/>
</Form.Item>
<Button
onClick={handleSelectSSHKeyFile}
loading={selectingSSHKey}
>
...
</Button>
</Space.Compact>
</Form.Item>
{renderStoredSecretControls({
fieldName: "sshPassword",
clearKey: "sshPassword",
hasStoredSecret: initialValues?.hasSSHPassword,
clearLabel: "清除已保存 SSH 密码",
description:
"当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。",
})}
</div>
)}
</div>
);
}
if (resolvedNetworkConfig === "proxy") {
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div
style={{
marginBottom: 8,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}>
</div>
{!useProxy ? (
<div
style={{
...modalMutedTextStyle,
padding: "10px 12px",
borderRadius: 12,
background: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.04)",
}}
>
</div>
) : (
<div style={tunnelSectionStyle}>
<Form.Item
name="proxyHost"
label="代理主机"
rules={[
{ required: useProxy, message: "请输入代理主机" },
]}
>
<Input
{...noAutoCapInputProps}
placeholder="例如: 127.0.0.1 或 proxy.company.com"
/>
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
gap: 16,
}}
>
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{renderChoiceCards({
fieldName: "proxyType",
value: String(proxyType),
minWidth: 150,
options: [
{
value: "socks5",
label: "SOCKS5",
description: "常见本地代理和网关代理。",
},
{
value: "http",
label: "HTTP CONNECT",
description: "通过 HTTP CONNECT 建立隧道。",
},
],
})}
</div>
<Form.Item
name="proxyPort"
label="端口"
rules={[
{ required: useProxy, message: "请输入代理端口" },
]}
style={{ marginBottom: 0 }}
>
<InputNumber
style={{ width: "100%" }}
min={1}
max={65535}
/>
</Form.Item>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="proxyUser"
label="代理用户名(可选)"
style={{ flex: 1 }}
>
<Input
{...noAutoCapInputProps}
placeholder="留空表示无认证"
/>
</Form.Item>
<Form.Item
name="proxyPassword"
label="代理密码(可选)"
style={{ flex: 1 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasProxyPassword,
emptyPlaceholder: "留空表示无认证",
retainedLabel: "已保存代理密码",
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: "proxyPassword",
clearKey: "proxyPassword",
hasStoredSecret: initialValues?.hasProxyPassword,
clearLabel: "清除已保存代理密码",
description:
"当前已保存代理密码。留空表示继续沿用,输入新值表示替换。",
})}
</div>
)}
</div>
);
}
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div
style={{
marginBottom: 8,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
HTTP
</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}>
HTTP CONNECT
</div>
{!useHttpTunnel ? (
<div
style={{
...modalMutedTextStyle,
padding: "10px 12px",
borderRadius: 12,
background: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.04)",
}}
>
HTTP
</div>
) : (
<div style={tunnelSectionStyle}>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
gap: 16,
}}
>
<Form.Item
name="httpTunnelHost"
label="隧道主机"
rules={[
{
required: useHttpTunnel,
message: "请输入隧道主机",
},
]}
style={{ flex: 1 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如: tunnel.company.com 或 127.0.0.1"
/>
</Form.Item>
<Form.Item
name="httpTunnelPort"
label="端口"
rules={[
{
required: useHttpTunnel,
message: "请输入隧道端口",
},
]}
style={{ width: 120 }}
>
<InputNumber
style={{ width: "100%" }}
min={1}
max={65535}
/>
</Form.Item>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="httpTunnelUser"
label="隧道用户名(可选)"
style={{ flex: 1 }}
>
<Input
{...noAutoCapInputProps}
placeholder="留空表示无认证"
/>
</Form.Item>
<Form.Item
name="httpTunnelPassword"
label="隧道密码(可选)"
style={{ flex: 1 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasHttpTunnelPassword,
emptyPlaceholder: "留空表示无认证",
retainedLabel: "已保存隧道密码",
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: "httpTunnelPassword",
clearKey: "httpTunnelPassword",
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
clearLabel: "清除已保存隧道密码",
description:
"当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。",
})}
<Text type="secondary" style={{ fontSize: 12 }}>
使 HTTP CONNECT
</Text>
</div>
)}
</div>
);
};
return (
<div style={modalInnerSectionStyle}>
<div
style={{
marginBottom: 12,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 14,
fontWeight: 700,
}}
>
</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 16 }}>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 12,
marginBottom: 16,
}}
>
{networkItems.map((item) => {
const active = item.key === resolvedNetworkConfig;
const activeColor = darkMode ? "#ffd666" : "#1677ff";
return (
<div
key={item.key}
role="button"
tabIndex={0}
onClick={() => 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",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<div
style={{
width: 8,
height: 8,
marginTop: 8,
borderRadius: 999,
background: active ? activeColor : "transparent",
border: active
? "none"
: darkMode
? "1px solid rgba(255,255,255,0.12)"
: "1px solid rgba(16,24,40,0.12)",
flexShrink: 0,
}}
/>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
<Form.Item
name={
item.key === "ssl"
? "useSSL"
: item.key === "ssh"
? "useSSH"
: item.key === "proxy"
? "useProxy"
: "useHttpTunnel"
}
valuePropName="checked"
noStyle
>
<Checkbox />
</Form.Item>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<span
style={{
fontSize: 14,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
{item.title}
</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
{active && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: activeColor,
background: darkMode
? "rgba(255,214,102,0.16)"
: "rgba(24,144,255,0.12)",
}}
>
</span>
)}
<span
style={{
fontSize: 11,
fontWeight: 700,
color: item.enabled
? activeColor
: darkMode
? "rgba(255,255,255,0.38)"
: "rgba(16,24,40,0.36)",
}}
>
{item.enabled ? "已启用" : "未启用"}
</span>
</div>
</div>
<div
style={{
marginTop: 4,
...modalMutedTextStyle,
color: active
? darkMode
? "rgba(255,255,255,0.72)"
: "rgba(22,32,51,0.68)"
: modalMutedTextStyle.color,
}}
>
{item.description}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
<div style={{ marginBottom: 16 }}>{renderNetworkPanel()}</div>
<div style={{ ...modalInnerSectionStyle, padding: 12 }}>
<div
style={{
marginBottom: 10,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 13,
fontWeight: 700,
}}
>
</div>
<Form.Item
name="timeout"
label="连接超时 (秒)"
help="数据库连接超时时间,默认 30 秒"
rules={[
{
type: "number",
min: 1,
max: 300,
message: "超时时间范围: 1-300 秒",
},
]}
style={{ marginBottom: 0 }}
>
<InputNumber
style={{ width: "100%" }}
min={1}
max={300}
placeholder="30"
/>
</Form.Item>
</div>
</div>
);
})()
: null;
return (
<Form
form={form}
layout="vertical"
initialValues={{
type: "mysql",
host: "localhost",
port: 3306,
database: "",
user: "root",
useSSL: false,
sslMode: "preferred",
sslCertPath: "",
sslKeyPath: "",
useSSH: false,
sshPort: 22,
useProxy: false,
proxyType: "socks5",
proxyPort: 1080,
useHttpTunnel: false,
httpTunnelPort: 8080,
timeout: 30,
uri: "",
mysqlTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
mongoReadPreference: "primary",
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: "",
mysqlReplicaPassword: "",
mongoReplicaUser: "",
mongoReplicaPassword: "",
redisDB: 0,
jvmReadOnly: true,
jvmAllowedModes: ["jmx"],
jvmPreferredMode: "jmx",
jvmEnvironment: "dev",
jvmEndpointEnabled: false,
jvmEndpointBaseUrl: "",
jvmEndpointApiKey: "",
jvmAgentEnabled: false,
jvmAgentBaseUrl: "",
jvmAgentApiKey: "",
jvmDiagnosticEnabled: false,
jvmDiagnosticTransport: "agent-bridge",
jvmDiagnosticBaseUrl: "",
jvmDiagnosticTargetId: "",
jvmDiagnosticApiKey: "",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: false,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 15,
jvmEndpointTimeoutSeconds: 30,
jvmJmxHost: "",
jvmJmxPort: undefined,
jvmJmxUsername: "",
jvmJmxPassword: "",
}}
onValuesChange={(changed) => {
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([]);
}
}}
>
<Form.Item name="type" hidden>
<Input {...noAutoCapInputProps} />
</Form.Item>
{currentDriverUnavailableReason && (
<Alert
showIcon
type="warning"
style={{ marginBottom: 12 }}
message="当前数据源驱动未启用"
description={
<Space size={8}>
<span>{currentDriverUnavailableReason}</span>
<Button
type="link"
size="small"
onClick={() => onOpenDriverManager?.()}
>
</Button>
</Space>
}
/>
)}
{(() => {
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: <DatabaseOutlined />,
},
...(!isCustom && !isFileDb && !isJVM
? [
{
key: "network" as const,
title: "网络与安全",
description: "SSL、SSH、代理与高级连接",
icon: <CloudOutlined />,
},
]
: []),
{
key: "appearance",
title: "外观",
description: "自定义图标与颜色",
icon: <BgColorsOutlined />,
},
];
const resolvedSection = sectionItems.some(
(item) => item.key === activeConfigSection,
)
? activeConfigSection
: sectionItems[0]?.key || "basic";
const effectiveIconType = customIconType || dbType;
const effectiveIconColor =
customIconColor || getDbDefaultColor(effectiveIconType);
const appearanceSection = (
<div style={{ display: "grid", gap: 18 }}>
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
<div
style={{
marginBottom: 12,
fontSize: 13,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{DB_ICON_TYPES.map((iconKey) => {
const isActive = effectiveIconType === iconKey;
return (
<button
key={iconKey}
type="button"
title={getDbIconLabel(iconKey)}
onClick={() =>
setCustomIconType(
iconKey === dbType ? undefined : iconKey,
)
}
style={{
width: 44,
height: 44,
borderRadius: 10,
display: "grid",
placeItems: "center",
border: `2px solid ${isActive ? effectiveIconColor : darkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"}`,
background: isActive
? darkMode
? "rgba(255,255,255,0.08)"
: "rgba(24,144,255,0.06)"
: "transparent",
cursor: "pointer",
transition: "all 120ms ease",
}}
>
{getDbIcon(
iconKey,
isActive ? effectiveIconColor : undefined,
22,
)}
</button>
);
})}
</div>
<div
style={{
marginTop: 6,
fontSize: 11,
color: darkMode
? "rgba(255,255,255,0.45)"
: "rgba(0,0,0,0.35)",
}}
>
{getDbIconLabel(effectiveIconType)}
</div>
</div>
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
<div
style={{
marginBottom: 12,
fontSize: 13,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
}}
>
{PRESET_ICON_COLORS.map((presetColor) => {
const isActive = effectiveIconColor === presetColor;
return (
<button
key={presetColor}
type="button"
onClick={() =>
setCustomIconColor(
presetColor === getDbDefaultColor(effectiveIconType)
? undefined
: presetColor,
)
}
style={{
width: 28,
height: 28,
borderRadius: 8,
background: presetColor,
border: isActive
? `2.5px solid ${darkMode ? "#fff" : "#162033"}`
: "2px solid transparent",
cursor: "pointer",
transition: "all 120ms ease",
boxShadow: isActive
? `0 0 0 2px ${presetColor}40`
: "none",
}}
/>
);
})}
<input
type="color"
value={effectiveIconColor}
onChange={(e) =>
setCustomIconColor(
e.target.value === getDbDefaultColor(effectiveIconType)
? undefined
: e.target.value,
)
}
title="自定义颜色"
style={{
width: 28,
height: 28,
border: "none",
padding: 0,
cursor: "pointer",
borderRadius: 6,
background: "transparent",
}}
/>
</div>
</div>
<div
style={{
...modalInnerSectionStyle,
padding: 16,
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{getDbIcon(effectiveIconType, effectiveIconColor, 24)}
<span
style={{
fontSize: 14,
color: darkMode ? "#e0e0e0" : "#333",
}}
>
{form.getFieldValue("name") || "连接名称"}
</span>
</div>
{(customIconType || customIconColor) && (
<Button
size="small"
type="link"
onClick={() => {
setCustomIconType(undefined);
setCustomIconColor(undefined);
}}
>
</Button>
)}
</div>
</div>
);
const currentSectionContent =
resolvedSection === "basic"
? baseInfoSection
: resolvedSection === "appearance"
? appearanceSection
: networkSecuritySection;
if (sectionItems.length <= 1) {
return currentSectionContent;
}
return (
<div
style={{
display: "grid",
gridTemplateColumns: "220px minmax(0, 1fr)",
gap: 18,
alignItems: "start",
}}
>
<div
style={{
...modalInnerSectionStyle,
padding: 12,
position: "sticky",
top: 0,
}}
>
<div
style={{
marginBottom: 12,
color: darkMode ? "#f5f7ff" : "#162033",
fontSize: 13,
fontWeight: 700,
letterSpacing: 0.2,
}}
>
</div>
<div style={{ display: "grid", gap: 10 }}>
{sectionItems.map((item) => {
const active = item.key === resolvedSection;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveConfigSection(item.key)}
style={{
textAlign: "left",
padding: "12px 12px 12px 14px",
borderRadius: 14,
border: `1px solid ${
active
? darkMode
? "rgba(255,214,102,0.3)"
: "rgba(24,144,255,0.24)"
: darkMode
? "rgba(255,255,255,0.045)"
: "rgba(16,24,40,0.055)"
}`,
background: active
? darkMode
? "linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)"
: "linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)"
: darkMode
? "rgba(255,255,255,0.02)"
: "rgba(255,255,255,0.7)",
color: active
? darkMode
? "#f5f7ff"
: "#162033"
: darkMode
? "rgba(255,255,255,0.76)"
: "#3f4b5e",
cursor: "pointer",
transition: "all 120ms ease",
boxShadow: active
? darkMode
? "0 10px 24px rgba(0,0,0,0.18)"
: "0 10px 22px rgba(24,144,255,0.08)"
: "none",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: 10,
display: "grid",
placeItems: "center",
flexShrink: 0,
background: active
? darkMode
? "rgba(255,214,102,0.16)"
: "rgba(24,144,255,0.14)"
: darkMode
? "rgba(255,255,255,0.05)"
: "rgba(16,24,40,0.05)",
color: active
? darkMode
? "#ffd666"
: "#1677ff"
: darkMode
? "rgba(255,255,255,0.55)"
: "#627089",
}}
>
{item.icon}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<span style={{ fontSize: 14, fontWeight: 700 }}>
{item.title}
</span>
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
background: active
? darkMode
? "#ffd666"
: "#1677ff"
: "transparent",
border: active
? "none"
: darkMode
? "1px solid rgba(255,255,255,0.12)"
: "1px solid rgba(16,24,40,0.12)",
}}
/>
</div>
<div
style={{
marginTop: 5,
fontSize: 12,
lineHeight: 1.55,
color: active
? darkMode
? "rgba(255,255,255,0.68)"
: "rgba(22,32,51,0.68)"
: darkMode
? "rgba(255,255,255,0.42)"
: "rgba(63,75,94,0.62)",
}}
>
{item.description}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0 }}>{currentSectionContent}</div>
</div>
);
})()}
</Form>
);
};
const getFooter = () => {
if (step === 1) {
return [
<Button key="cancel" onClick={onClose}>
</Button>,
];
}
const isTestSuccess = testResult?.type === "success";
const hasTestError = !!testResult && !isTestSuccess;
const testFailureSummary = hasTestError
? summarizeConnectionTestFailureMessage(testResult?.message, "连接失败")
: "";
const operationBlocked =
!!currentDriverUnavailableReason ||
driverStatusChecking ||
!!unsupportedJvmModeMessage;
return (
<div
style={{
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "4px 2px 0",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
{!initialValues && (
<Button key="back" onClick={() => setStep(1)}>
</Button>
)}
{testResult ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
height: 24,
padding: "0 10px",
borderRadius: 999,
border: isTestSuccess
? "1px solid rgba(82, 196, 26, 0.35)"
: "1px solid rgba(255, 77, 79, 0.35)",
background: isTestSuccess
? "rgba(82, 196, 26, 0.10)"
: "rgba(255, 77, 79, 0.10)",
color: isTestSuccess ? "#389e0d" : "#cf1322",
fontSize: 12,
lineHeight: "22px",
whiteSpace: "nowrap",
boxSizing: "border-box",
}}
>
{isTestSuccess ? <CheckCircleFilled /> : <CloseCircleFilled />}
<span>{isTestSuccess ? "连接成功" : "连接失败"}</span>
</span>
) : null}
{hasTestError && (
<span
data-connection-test-error-summary="true"
title={testFailureSummary}
style={{
minWidth: 0,
flex: 1,
color: "#cf1322",
fontSize: 12,
lineHeight: "20px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{testFailureSummary}
</span>
)}
{hasTestError && (
<Button
size="small"
icon={<FileTextOutlined />}
style={{
height: 24,
borderRadius: 999,
padding: "0 10px",
borderColor: "#ffccc7",
background: "#fff2f0",
color: "#cf1322",
}}
onClick={() => setTestErrorLogOpen(true)}
>
</Button>
)}
</div>
<Space size={8} style={{ flexShrink: 0 }}>
<Button
key="test"
loading={loading}
disabled={operationBlocked}
onClick={requestTest}
>
</Button>
<Button key="cancel" onClick={onClose}>
</Button>
<Button
key="submit"
type="primary"
loading={loading}
disabled={operationBlocked}
onClick={handleOk}
>
</Button>
</Space>
</div>
);
};
const getTitle = () => {
if (step === 1) {
return renderConnectionModalTitle(
<AppstoreOutlined />,
"选择数据源类型",
"按数据库、中间件或文件类型快速进入对应的连接配置流程。",
);
}
const typeName = dbTypes.find((t) => t.key === dbType)?.name || dbType;
return initialValues
? renderConnectionModalTitle(
<EditOutlined />,
"编辑连接",
`调整 ${typeName} 连接的参数、认证方式与网络选项。`,
)
: renderConnectionModalTitle(
<LinkOutlined />,
`新建 ${typeName} 连接`,
"填写连接参数、测试连通性,并保存到连接树中。",
);
};
const modalBodyStyle = {
padding: "12px 24px 18px",
height: CONNECTION_MODAL_BODY_HEIGHT,
overflowY: "auto" as const,
overflowX: "hidden" as const,
};
return (
<>
<Modal
title={getTitle()}
open={open}
onCancel={onClose}
footer={getFooter()}
centered
wrapClassName="connection-modal-wrap"
width={CONNECTION_MODAL_WIDTH}
zIndex={10001}
destroyOnHidden
maskClosable={false}
styles={{
content: modalShellStyle,
header: {
background: "transparent",
borderBottom: "none",
paddingBottom: 8,
},
body: modalBodyStyle,
footer: {
background: "transparent",
borderTop: "none",
paddingTop: 10,
},
}}
>
{step === 1 ? renderStep1() : renderStep2()}
</Modal>
<Modal
title={renderConnectionModalTitle(
<FileTextOutlined />,
"测试连接失败原因",
"查看本次测试连接的完整错误上下文,便于快速定位配置问题。",
)}
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={[
<Button key="close" onClick={() => setTestErrorLogOpen(false)}>
</Button>,
]}
>
<pre
style={{
margin: 0,
maxHeight: "50vh",
overflowY: "auto",
padding: 12,
borderRadius: 6,
background: "#fff2f0",
border: "1px solid #ffccc7",
color: "#a8071a",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
lineHeight: "20px",
fontSize: 13,
}}
>
{String(testResult?.message || "暂无失败日志")}
</pre>
</Modal>
</>
);
};
export default ConnectionModal;