Files
MyGoNavi/frontend/src/components/ConnectionModal.tsx
Syngnat 69d9f41085 feat(connection): 支持生产保护默认折叠
- 为连接配置分区卡片增加可折叠渲染能力

- 生产连接保护默认收起,仅保留标题说明和当前策略标签

- 补充中英文折叠态与展开态回归测试
2026-07-02 14:12:47 +08:00

2848 lines
89 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo } from "react";
import Modal from './common/ResizableDraggableModal';
import {
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,
DownOutlined,
RightOutlined,
} 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 { t } from "../i18n";
import {
getConnectionConfigLayoutKindLabel,
getConnectionConfigSectionCopy,
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionConfigLayout,
summarizeConnectionTestFailureMessage,
type ConnectionConfigSectionKey,
} from "../utils/connectionModalPresentation";
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveConnectionProtectionConfig } from "../utils/connectionReadOnly";
import { getCustomConnectionDriverHelp } from "../utils/driverImportGuidance";
import { isBackendCancelledResult } from "../utils/connectionExport";
import {
buildUriFromValues,
getConnectionParamsPlaceholder,
getUriPlaceholder,
normalizeAddressList,
normalizeClickHouseProtocolValue,
normalizeConnectionParamsText,
normalizeFileDbPath,
normalizeMongoSrvHostList,
normalizeOceanBaseConnectionParamsText,
normalizeOceanBaseProtocolValue,
parseClickHouseHTTPUriToValues,
parseHostPort,
parseUriToValues,
toAddress,
type ClickHouseProtocolChoice,
type OceanBaseProtocolChoice,
} from "./connectionModal/connectionModalUri";
import {
buildConnectionConfig,
buildSavedConnectionInput,
createEmptyConnectionSecretClearState,
getBlockingSecretClearMessage,
type ConnectionSecretClearState,
type ConnectionSecretKey,
} from "./connectionModal/connectionModalConfig";
import ConnectionModalStep2 from "./connectionModal/ConnectionModalStep2";
import {
buildConnectionTypeGroups,
getAllConnectionTypeCatalogItems,
getConnectionTypeDefaultPort as getDefaultPortByType,
getConnectionTypeHint,
} from "../utils/connectionTypeCatalog";
import {
isFileDatabaseType,
isMySQLCompatibleType,
supportsConnectionParamsForType,
supportsSSLCAPathForType,
supportsSSLClientCertificateForType,
supportsSSLForType,
} from "../utils/connectionTypeCapabilities";
import {
normalizeDriverType,
resolveConnectionDriverType,
type DriverStatusSnapshot,
} from "../utils/connectionDriverType";
import {
normalizeOceanBaseProtocol,
resolveOceanBaseProtocolFromQueryText as resolveOceanBaseProtocolQueryText,
} from "../utils/oceanBaseProtocol";
import {
applyNoAutoCapAttributes,
noAutoCapInputProps,
} from "../utils/inputAutoCap";
import {
buildDefaultJVMConnectionValues,
hasUnsupportedJVMEditableModes,
JVM_EDITABLE_MODES,
normalizeEditableJVMModes,
resolveEditableJVMModeSelection,
} from "../utils/jvmConnectionConfig";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import {
DBGetDatabases,
GetDriverStatusList,
MongoDiscoverMembers,
TestConnection,
RedisConnect,
RedisGetDatabases,
SelectDatabaseFile,
SelectCertificateFile,
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_TIMEOUT_SECONDS = 3600;
const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240;
const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([
"mongodb",
"elasticsearch",
"chroma",
"qdrant",
"rocketmq",
"mqtt",
"kafka",
"rabbitmq",
]);
const CONNECTION_MODAL_WIDTH = 960;
const CONNECTION_MODAL_BODY_HEIGHT = 620;
const REDIS_DEFAULT_DATABASE_COUNT = 16;
const STEP1_SIDEBAR_DIVIDER_DARK = "rgba(255, 255, 255, 0.16)";
const STEP1_SIDEBAR_DIVIDER_LIGHT = "rgba(0, 0, 0, 0.08)";
const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{
value: ClickHouseProtocolChoice;
label?: string;
labelKey?: string;
}> = [
{ value: "auto", labelKey: "connection.modal.field.clickHouseProtocol.auto" },
{ value: "http", label: "HTTP" },
{ value: "native", label: "Native" },
];
const OCEANBASE_PROTOCOL_OPTIONS: Array<{
value: OceanBaseProtocolChoice;
label: string;
}> = [
{ value: "mysql", label: "MySQL" },
{ value: "oracle", label: "Oracle" },
];
const normalizeRedisDatabaseIndex = (value: unknown): number | null => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.trunc(parsed);
};
const buildRedisDatabaseList = (...values: unknown[]): number[] => {
const indexes = new Set<number>();
for (let i = 0; i < REDIS_DEFAULT_DATABASE_COUNT; i += 1) {
indexes.add(i);
}
const collect = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(collect);
return;
}
const index = normalizeRedisDatabaseIndex(value);
if (index !== null) {
indexes.add(index);
}
};
values.forEach(collect);
return Array.from(indexes).sort((a, b) => a - b);
};
const extractRedisDatabaseList = (value: unknown): number[] => {
if (!Array.isArray(value)) return [];
const indexes = new Set<number>();
value.forEach((row: any) => {
const index = normalizeRedisDatabaseIndex(row?.index ?? row?.Index);
if (index !== null) {
indexes.add(index);
}
});
const result = Array.from(indexes).sort((a, b) => a - b);
return result.length > 0 ? result : buildRedisDatabaseList();
};
const normalizeRedisDatabaseSelection = (
value: unknown,
supportedDbs: number[],
): number[] | undefined => {
if (!Array.isArray(value)) return undefined;
const supported = new Set(supportedDbs);
const selected = Array.from(
new Set(
value
.map(normalizeRedisDatabaseIndex)
.filter((index): index is number => index !== null)
.filter((index) => supported.size === 0 || supported.has(index)),
),
).sort((a, b) => a - b);
return selected.length > 0 ? selected : undefined;
};
const resolveOceanBaseProtocolValue = (
value: unknown,
): OceanBaseProtocolChoice | undefined => {
const text = String(value || "")
.trim()
.toLowerCase();
if (!text) return undefined;
return normalizeOceanBaseProtocol(text);
};
const resolveOceanBaseProtocolFromQueryText = (
value: unknown,
): OceanBaseProtocolChoice | undefined => {
return resolveOceanBaseProtocolQueryText(value).protocol;
};
const resolveOceanBaseProtocolForConfig = (
config: Partial<ConnectionConfig>,
): OceanBaseProtocolChoice => {
return (
resolveOceanBaseProtocolValue(config.oceanBaseProtocol) ||
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
"mysql"
);
};
type UriFeedbackState = {
type: "success" | "warning" | "error";
messageKey: string;
};
type TestFailureKind =
| "validation"
| "runtime"
| "driver_unavailable"
| "secret_blocked";
type TestResultState =
| {
type: "success";
message: string;
}
| {
type: "error";
kind: TestFailureKind;
reason: string;
fallbackKey: string;
};
const resolveInitialSecretFieldValue = (
initialValues: SavedConnection | null | undefined,
fieldName: string,
): string => {
if (!initialValues) {
return "";
}
const config = initialValues.config || ({} as ConnectionConfig);
switch (fieldName) {
case "password":
return String(config.password || "");
case "sshPassword":
return String(config.ssh?.password || "");
case "proxyPassword":
return String(config.proxy?.password || "");
case "httpTunnelPassword":
return String(config.httpTunnel?.password || "");
case "mysqlReplicaPassword":
return String(config.mysqlReplicaPassword || "");
case "mongoReplicaPassword":
return String(config.mongoReplicaPassword || "");
case "redisSentinelPassword":
return String(config.redisSentinelPassword || "");
case "uri":
return String(config.uri || "");
case "dsn":
return String(config.dsn || "");
default:
return "";
}
};
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<TestResultState | null>(null);
const [testErrorLogOpen, setTestErrorLogOpen] = useState(false);
const [dbList, setDbList] = useState<string[]>([]);
const [redisDbList, setRedisDbList] = useState<number[]>([]);
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
const [discoveringMembers, setDiscoveringMembers] = useState(false);
const [uriFeedback, setUriFeedback] = useState<UriFeedbackState | 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 [selectingCertificateField, setSelectingCertificateField] = useState<
"sslCAPath" | "sslCertPath" | "sslKeyPath" | null
>(null);
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(
createEmptyConnectionSecretClearState,
);
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
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 languagePreference = useStore((state) => state.languagePreference);
void languagePreference;
const darkMode = theme === "dark";
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(
resolvedAppearance.opacity,
);
const disableLocalBackdropFilter = isMacLikePlatform();
const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single";
const rocketmqTopology = Form.useWatch("rocketmqTopology", form) || "single";
const mqttTopology = Form.useWatch("mqttTopology", form) || "single";
const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single";
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
const redisTopology = Form.useWatch("redisTopology", form) || "single";
const oceanBaseProtocol = normalizeOceanBaseProtocolValue(
Form.useWatch("oceanBaseProtocol", form),
);
const sslMode = Form.useWatch("sslMode", form) || "preferred";
const proxyType = Form.useWatch("proxyType", form) || "socks5";
const customDriver = Form.useWatch("driver", form) || "";
const mongoReadPreference =
Form.useWatch("mongoReadPreference", form) || "primary";
const mongoAuthMechanism = Form.useWatch("mongoAuthMechanism", form) || "";
const jvmEnvironment = Form.useWatch("jvmEnvironment", form) || "dev";
const jvmAllowedModes = Form.useWatch("jvmAllowedModes", form);
const jvmPreferredMode = Form.useWatch("jvmPreferredMode", form) || "jmx";
const jvmDiagnosticEnabled =
Form.useWatch("jvmDiagnosticEnabled", form) || false;
const jvmDiagnosticTransport =
Form.useWatch("jvmDiagnosticTransport", form) || "agent-bridge";
const normalizedJvmAllowedModes = useMemo(
() => normalizeEditableJVMModes(jvmAllowedModes),
[jvmAllowedModes],
);
const hasUnsupportedJvmModeSelection = useMemo(
() =>
hasUnsupportedJVMEditableModes({
allowedModes: jvmAllowedModes,
preferredMode: jvmPreferredMode,
}),
[jvmAllowedModes, jvmPreferredMode],
);
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
const isRocketMQ = dbType === "rocketmq";
const isMQTT = dbType === "mqtt";
const isKafka = dbType === "kafka";
const isRabbitMQ = dbType === "rabbitmq";
const supportsConnectionParams = supportsConnectionParamsForType(dbType);
const isSSLType = supportsSSLForType(dbType);
const supportsSSLCAPath = supportsSSLCAPathForType(dbType);
const supportsSSLClientCertificate =
supportsSSLClientCertificateForType(dbType);
const sslHintText = isMySQLLike
? t("connection.modal.network.ssl.hint.mysqlCompatible")
: isOceanBaseOracle
? t("connection.modal.network.ssl.hint.oceanBaseOracle")
: dbType === "dameng"
? t("connection.modal.network.ssl.hint.dameng")
: dbType === "sqlserver"
? t("connection.modal.network.ssl.hint.sqlserver")
: dbType === "mongodb"
? t("connection.modal.network.ssl.hint.mongodb")
: dbType === "oracle"
? t("connection.modal.network.ssl.hint.oracle")
: dbType === "tdengine"
? t("connection.modal.network.ssl.hint.tdengine")
: t("connection.modal.network.ssl.hint.default");
const resolvedUriFeedbackMessage = uriFeedback
? t(uriFeedback.messageKey)
: "";
const resolvedTestResultMessage = !testResult
? ""
: testResult.type === "success"
? String(testResult.message || "")
: testResult.kind === "validation"
? t("connection.modal.test.validation")
: t("connection.modal.test.failure", {
reason: normalizeConnectionSecretErrorMessage(
testResult.reason,
t(testResult.fallbackKey),
),
});
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,
uiVersion: appearance.uiVersion,
}),
[appearance.uiVersion, 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 initialSecretValue = resolveInitialSecretFieldValue(
initialValues,
fieldName,
);
const normalizedDraftValue = String(draftValue ?? "");
const matchesInitialSecret =
initialSecretValue !== "" &&
normalizedDraftValue === initialSecretValue;
const hasDraftValue =
normalizedDraftValue !== "" && !matchesInitialSecret;
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
? t("connection.modal.secret.draftReplacement")
: description}
</div>
<Checkbox
checked={effectiveChecked}
disabled={hasDraftValue}
onChange={(event) => {
const checked = event.target.checked;
if (checked && matchesInitialSecret) {
form.setFieldValue(fieldName, "");
}
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,
options?: {
cursor?: React.CSSProperties["cursor"];
marginBottom?: number;
onClick?: () => void;
},
) => (
<div
onClick={options?.onClick}
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: options?.marginBottom ?? 14,
cursor: options?.cursor,
}}
>
<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,
collapsible,
expanded = true,
onToggle,
}: {
sectionKey: ConnectionConfigSectionKey;
icon: React.ReactNode;
children: React.ReactNode;
badge?: React.ReactNode;
collapsible?: boolean;
expanded?: boolean;
onToggle?: () => void;
}) => {
const copy = getConnectionConfigSectionCopy(sectionKey);
const showChildren = !collapsible || expanded;
const resolvedBadge = collapsible ? (
<Space size={8}>
{badge}
<Button
type="text"
size="small"
aria-label={copy.title}
aria-expanded={expanded}
data-connection-config-section-toggle={sectionKey}
onClick={(event) => {
event.stopPropagation();
onToggle?.();
}}
style={{
width: 26,
height: 26,
minWidth: 26,
padding: 0,
borderRadius: 8,
color: overlayTheme.mutedText,
}}
>
{expanded ? <DownOutlined /> : <RightOutlined />}
</Button>
</Space>
) : badge;
return (
<div
data-connection-config-section={sectionKey}
style={configSectionCardStyle()}
>
{renderJvmSectionHeader(
icon,
copy.title,
copy.description,
resolvedBadge,
collapsible
? {
cursor: "pointer",
marginBottom: showChildren ? 14 : 0,
onClick: onToggle,
}
: undefined,
)}
{showChildren ? children : null}
</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 nextRedisTopology = String(value || "single").toLowerCase();
const currentRedisPort = Number(form.getFieldValue("port") || 0);
if (
nextRedisTopology === "sentinel" &&
(!currentRedisPort || currentRedisPort === 6379)
) {
form.setFieldValue("port", 26379);
} else if (
nextRedisTopology !== "sentinel" &&
currentRedisPort === 26379
) {
form.setFieldValue("port", 6379);
}
const supportedDbs = buildRedisDatabaseList(
form.getFieldValue("redisDB"),
form.getFieldValue("includeRedisDatabases"),
);
setRedisDbList(supportedDbs);
form.setFieldValue(
"includeRedisDatabases",
normalizeRedisDatabaseSelection(
form.getFieldValue("includeRedisDatabases"),
supportedDbs,
),
);
}
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">{t("connection.modal.choice.current")}</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,
expectedRevision: String(item.expectedRevision || "").trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || "").trim() || undefined,
affectedConnections: Number.isFinite(Number(item.affectedConnections))
? Number(item.affectedConnections)
: undefined,
message: String(item.message || "").trim() || undefined,
};
});
return result;
};
const refreshDriverStatus = async () => {
try {
const next = await fetchDriverStatusMap();
setDriverStatusMap(next);
} catch {
setDriverStatusMap({});
} finally {
setDriverStatusLoaded(true);
}
};
const resolveDriverUnavailableReason = async (
type: string,
driver?: string,
): Promise<string> => {
const normalized = resolveConnectionDriverType(type, driver);
if (!normalized || normalized === "custom") {
return "";
}
let snapshot = driverStatusMap;
if (!snapshot[normalized]) {
snapshot = await fetchDriverStatusMap();
setDriverStatusMap(snapshot);
}
const status = snapshot[normalized];
if (!status || status.connectable) {
return "";
}
return (
status.message ||
t("connection.modal.driver.unavailableFallback", {
name: status.name || normalized,
})
);
};
const promptInstallDriver = (driverType: string, reason: string) => {
const normalized = normalizeDriverType(driverType);
const snapshot = driverStatusMap[normalized];
const driverName =
snapshot?.name || normalized || t("connection.modal.driver.currentFallback");
Modal.confirm({
title: t("connection.modal.driver.unavailableTitle", {
name: driverName,
}),
content:
reason ||
t("connection.modal.driver.unavailableFallback", {
name: driverName,
}),
okText: t("connection.modal.driver.installAction"),
cancelText: t("common.action.cancel"),
onOk: () => {
onOpenDriverManager?.();
},
});
};
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 handleGenerateURI = () => {
try {
const values = form.getFieldsValue(true);
const uri = buildUriFromValues(values);
form.setFieldValue("uri", uri);
setUriFeedback({
type: "success",
messageKey: "connection.modal.uri.feedback.generated",
});
} catch {
setUriFeedback({
type: "error",
messageKey: "connection.modal.uri.feedback.generateFailed",
});
}
};
const handleParseURI = () => {
try {
const uriText = String(form.getFieldValue("uri") || "").trim();
const type = String(form.getFieldValue("type") || dbType)
.trim()
.toLowerCase();
if (!uriText) {
setUriFeedback({
type: "warning",
messageKey: "connection.modal.uri.feedback.emptyInput",
});
return;
}
const parsedValues = parseUriToValues(uriText, type);
if (!parsedValues) {
setUriFeedback({
type: "error",
messageKey: "connection.modal.uri.feedback.unsupported",
});
return;
}
form.setFieldsValue(
mergeParsedUriValuesForForm(
form.getFieldsValue(true),
parsedValues,
uriText,
),
);
if (testResult) {
setTestResult(null);
}
setUriFeedback({
type: "success",
messageKey: "connection.modal.uri.feedback.parsed",
});
} catch {
setUriFeedback({
type: "error",
messageKey: "connection.modal.uri.feedback.parseFailed",
});
}
};
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",
messageKey: "connection.modal.uri.feedback.emptyCopy",
});
return;
}
try {
await navigator.clipboard.writeText(uriText);
setUriFeedback({
type: "success",
messageKey: "connection.modal.uri.feedback.copied",
});
} catch {
setUriFeedback({
type: "error",
messageKey: "connection.modal.uri.feedback.copyFailed",
});
}
};
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 (!isBackendCancelledResult(res)) {
message.error(
t("connection.modal.filePicker.sshKeyFailure", {
detail: res?.message || t("connection.modal.error.unknown"),
}),
);
}
} catch (e: any) {
message.error(
t("connection.modal.filePicker.sshKeyFailure", {
detail: e?.message || String(e),
}),
);
} finally {
setSelectingSSHKey(false);
}
};
const handleSelectCertificateFile = async (
fieldName: "sslCAPath" | "sslCertPath" | "sslKeyPath",
certKind: "ca" | "client-cert" | "client-key",
) => {
if (selectingCertificateField) {
return;
}
try {
setSelectingCertificateField(fieldName);
const currentPath = String(form.getFieldValue(fieldName) || "").trim();
const res = await SelectCertificateFile(currentPath, certKind);
if (res?.success) {
const data = res.data || {};
const selectedPath =
typeof data === "string" ? data : String(data.path || "").trim();
if (selectedPath) {
form.setFieldValue(fieldName, selectedPath);
}
} else if (!isBackendCancelledResult(res)) {
message.error(
t("connection.modal.filePicker.certificateFailure", {
detail: res?.message || t("connection.modal.error.unknown"),
}),
);
}
} catch (e: any) {
message.error(
t("connection.modal.filePicker.certificateFailure", {
detail: e?.message || String(e),
}),
);
} finally {
setSelectingCertificateField(null);
}
};
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 (!isBackendCancelledResult(res)) {
message.error(
t("connection.modal.filePicker.databaseFailure", {
detail: res?.message || t("connection.modal.error.unknown"),
}),
);
}
} catch (e: any) {
message.error(
t("connection.modal.filePicker.databaseFailure", {
detail: 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());
setPrimaryPasswordVisible(false);
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 savedPrimaryAddress = isFileDbConfigType
? ""
: toAddress(
config.host || "localhost",
Number(config.port || defaultPort),
defaultPort,
);
const normalizedHosts = isFileDbConfigType
? []
: normalizeAddressList(
[
savedPrimaryAddress,
...(Array.isArray(config.hosts) ? config.hosts : []),
],
defaultPort,
);
const primaryAddress = isFileDbConfigType
? null
: parseHostPort(
normalizedHosts[0] ||
savedPrimaryAddress,
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 === "goldendb" ||
configType === "mariadb" ||
configType === "oceanbase" ||
configType === "diros" ||
configType === "starrocks" ||
configType === "sphinx"
? normalizedHosts.slice(1)
: [];
const rocketmqHosts =
configType === "rocketmq" ? normalizedHosts.slice(1) : [];
const mqttHosts =
configType === "mqtt" ? normalizedHosts.slice(1) : [];
const kafkaHosts =
configType === "kafka" ? 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 rocketmqIsCluster =
String(config.topology || "").toLowerCase() === "cluster" ||
rocketmqHosts.length > 0;
const mqttIsCluster =
String(config.topology || "").toLowerCase() === "cluster" ||
mqttHosts.length > 0;
const kafkaIsCluster =
String(config.topology || "").toLowerCase() === "cluster" ||
kafkaHosts.length > 0;
const mongoIsReplica =
String(config.topology || "").toLowerCase() === "replica" ||
mongoHosts.length > 0 ||
!!config.replicaSet;
const redisTopologyValue = String(config.topology || "").toLowerCase();
const redisIsSentinel = redisTopologyValue === "sentinel";
const redisIsCluster =
!redisIsSentinel &&
(redisTopologyValue === "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;
const protection = resolveConnectionProtectionConfig(config);
form.setFieldsValue({
type: configType,
name: initialValues.name,
host: primaryHost,
port: primaryPort,
user: config.user,
password: config.password,
database: config.database,
restrictDataEdit: protection.restrictDataEdit === true,
restrictStructureEdit: protection.restrictStructureEdit === true,
restrictScriptExecution:
protection.restrictScriptExecution === true,
restrictDataImport: protection.restrictDataImport === true,
uri: config.uri || "",
connectionParams:
config.connectionParams ||
(config.uri
? parseUriToValues(config.uri, configType)?.connectionParams || ""
: ""),
clickHouseProtocol:
configType === "clickhouse"
? normalizeClickHouseProtocolValue(config.clickHouseProtocol)
: "auto",
oceanBaseProtocol:
configType === "oceanbase"
? resolveOceanBaseProtocolForConfig(config)
: "mysql",
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
useSSL: !!config.useSSL,
sslMode: config.sslMode || "preferred",
sslCAPath: config.sslCAPath || "",
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,
keepAliveEnabled: !!config.keepAliveEnabled,
keepAliveIntervalMinutes:
Number(config.keepAliveIntervalMinutes) > 0
? Number(config.keepAliveIntervalMinutes)
: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
mysqlTopology: mysqlIsReplica ? "replica" : "single",
mysqlReplicaHosts: mysqlReplicaHosts,
rocketmqTopology: rocketmqIsCluster ? "cluster" : "single",
rocketmqHosts: rocketmqHosts,
mqttTopology: mqttIsCluster ? "cluster" : "single",
mqttHosts: mqttHosts,
kafkaTopology: kafkaIsCluster ? "cluster" : "single",
kafkaHosts: kafkaHosts,
mysqlReplicaUser: config.mysqlReplicaUser || "",
mysqlReplicaPassword: config.mysqlReplicaPassword || "",
mongoTopology: mongoIsReplica ? "replica" : "single",
mongoHosts: mongoHosts,
redisTopology: redisIsSentinel
? "sentinel"
: redisIsCluster
? "cluster"
: "single",
redisHosts: redisHosts,
redisSentinelMaster: config.redisSentinelMaster || "",
redisSentinelUser: config.redisSentinelUser || "",
redisSentinelPassword: config.redisSentinelPassword || "",
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 || ""
: "",
});
setPrimaryPasswordVisible(false);
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(
buildRedisDatabaseList(
config.redisDB,
initialValues.includeRedisDatabases,
),
);
}
} 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");
setPrimaryPasswordVisible(false);
}
}
}, [open, initialValues]);
useEffect(() => {
return () => {
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
testTimerRef.current = null;
}
};
}, []);
const handleOk = async () => {
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
values.driver,
);
if (unavailableReason) {
message.warning(unavailableReason);
promptInstallDriver(
resolveConnectionDriverType(values.type, values.driver) || values.type,
unavailableReason,
);
return;
}
setLoading(true);
const config = await buildConnectionConfig({
values,
forPersist: true,
initialValues,
translate: t,
});
const payload = buildSavedConnectionInput({
config,
values,
initialValues,
clearSecrets,
customIconType,
customIconColor,
});
const backendApp = (window as any).go?.app?.App;
const savedConnection = await backendApp?.SaveConnection?.(payload);
if (!savedConnection) {
throw new Error(t("connection.modal.save.backendUnavailable"));
}
if (initialValues) {
updateConnection(savedConnection);
message.success(t("connection.modal.save.updatedUnconnected"));
} else {
addConnection(savedConnection);
message.success(t("connection.modal.save.savedUnconnected"));
}
if (onSaved) {
void Promise.resolve(onSaved(savedConnection)).catch(
(error: unknown) => {
console.warn("Failed to refresh post-save state", error);
void message.warning(
t("connection.modal.save.refreshWarning"),
);
},
);
}
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,
t("connection.modal.save.failureFallback"),
),
);
} 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 applyTestFailureFeedback = ({
kind,
reason,
fallbackKey,
}: {
kind: TestFailureKind;
reason?: unknown;
fallbackKey: string;
}) => {
void message.destroy("connection-test-failure");
setTestResult({
type: "error",
kind,
reason: String(reason ?? ""),
fallbackKey,
});
};
const handleTest = async () => {
if (testInFlightRef.current) return;
testInFlightRef.current = true;
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
values.driver,
);
if (unavailableReason) {
applyTestFailureFeedback({
kind: "driver_unavailable",
reason: unavailableReason,
fallbackKey: "connection.modal.test.fallback.driverUnavailable",
});
promptInstallDriver(
resolveConnectionDriverType(values.type, values.driver) || values.type,
unavailableReason,
);
return;
}
const blockingSecretClearMessage = getBlockingSecretClearMessage({
values,
clearSecrets,
initialValues,
translate: t,
});
if (blockingSecretClearMessage) {
applyTestFailureFeedback({
kind: "secret_blocked",
reason: blockingSecretClearMessage,
fallbackKey: "connection.modal.test.fallback.incompleteParams",
});
return;
}
setLoading(true);
setTestResult(null);
const config = await buildConnectionConfig({
values,
forPersist: false,
initialValues,
translate: t,
});
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 dbTestConfig =
!isRedisType && !isJVMType ? buildRpcConnectionConfig(config as any) : config;
const res = await withClientTimeout(
isJVMType
? TestJVMConnection(config as any)
: isRedisType
? RedisConnect(config as any)
: TestConnection(dbTestConfig as any),
rpcTimeoutMs,
t("connection.modal.test.timeout", { seconds: timeoutSeconds }),
);
if (res.success) {
void message.destroy("connection-test-failure");
setTestResult({ type: "success", message: res.message });
if (isRedisType) {
const dbRes = await withClientTimeout(
RedisGetDatabases(config as any),
rpcTimeoutMs,
t("connection.modal.test.redis_database_list_timeout", {
seconds: timeoutSeconds,
}),
);
if (dbRes.success) {
const supportedDbs = extractRedisDatabaseList(dbRes.data);
setRedisDbList(supportedDbs);
form.setFieldValue(
"includeRedisDatabases",
normalizeRedisDatabaseSelection(
form.getFieldValue("includeRedisDatabases"),
supportedDbs,
),
);
} else {
setRedisDbList(
buildRedisDatabaseList(
config.redisDB,
form.getFieldValue("includeRedisDatabases"),
),
);
message.warning(
t("connection.modal.test.redis_database_list_failure", {
detail: normalizeConnectionSecretErrorMessage(
dbRes.message,
t("connection.modal.error.unknown"),
),
}),
);
}
} else if (!isJVMType) {
// Other databases: fetch database list
const dbRes = await withClientTimeout(
DBGetDatabases(dbTestConfig as any),
rpcTimeoutMs,
t("connection.modal.test.databaseListTimeout", {
seconds: 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"
? t("connection.modal.test.noVisibleSchema")
: t("connection.modal.test.noVisibleDatabaseList"),
);
}
} else {
setDbList([]);
message.warning(
t("connection.modal.test.databaseListFailure", {
detail: normalizeConnectionSecretErrorMessage(
dbRes.message,
t("connection.modal.error.unknown"),
),
}),
);
}
}
} else {
applyTestFailureFeedback({
kind: "runtime",
reason: res?.message,
fallbackKey: "connection.modal.test.fallback.rejected",
});
}
} catch (e: unknown) {
if (e && typeof e === "object" && "errorFields" in e) {
applyTestFailureFeedback({
kind: "validation",
fallbackKey: "connection.modal.test.fallback.validation",
});
return;
}
const reason =
e instanceof Error
? e.message
: typeof e === "string"
? e
: t("connection.modal.test.fallback.unknownException");
applyTestFailureFeedback({
kind: "runtime",
reason,
fallbackKey: "connection.modal.test.fallback.unknownException",
});
} 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,
clearSecrets,
initialValues,
translate: t,
});
if (blockingSecretClearMessage) {
message.error(blockingSecretClearMessage);
return;
}
const config = await buildConnectionConfig({
values,
forPersist: false,
initialValues,
translate: t,
});
if (initialValues?.id) {
config.id = initialValues.id;
}
const result = await MongoDiscoverMembers(config as any);
if (!result.success) {
message.error(
normalizeConnectionSecretErrorMessage(
result.message,
t("connection.modal.mongo.discover.failure"),
),
);
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 || "").trim(),
state: String(item.state || item.role || "").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 ||
t(
members.length === 1
? "connection.modal.mongo.discover.successOne"
: "connection.modal.mongo.discover.successMany",
{ count: members.length },
),
);
} catch (error: any) {
message.error(
normalizeConnectionSecretErrorMessage(
error?.message || error,
t("connection.modal.mongo.discover.failure"),
),
);
} finally {
setDiscoveringMembers(false);
}
};
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 ||
t("connection.modal.driver.unavailableFallback", {
name: driverName,
});
setTypeSelectWarning({ driverName, reason });
return;
}
setTypeSelectWarning(null);
setDbType(type);
form.setFieldsValue({
type: type,
clickHouseProtocol: type === "clickhouse" ? "auto" : undefined,
oceanBaseProtocol: type === "oceanbase" ? "mysql" : undefined,
});
const defaultPort = getDefaultPortByType(type);
if (type === "jvm") {
const jvmDefaultValues = buildDefaultJVMConnectionValues();
setUseSSL(false);
setUseSSH(false);
setUseProxy(false);
setUseHttpTunnel(false);
form.setFieldsValue({
...jvmDefaultValues,
user: "",
password: "",
database: "",
useSSL: false,
sslMode: undefined,
sslCAPath: 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,
keepAliveEnabled: false,
keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
uri: "",
connectionParams: "",
includeDatabases: undefined,
includeRedisDatabases: undefined,
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
mongoReadPreference: "primary",
mongoReplicaSet: "",
mongoAuthSource: "",
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",
redisSentinelPassword: "",
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",
sslCAPath: "",
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: "",
keepAliveEnabled: false,
keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
mongoReadPreference: "primary",
mongoReplicaSet: "",
mongoAuthSource: "",
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",
redisSentinelPassword: "",
mongoHosts: [],
mysqlReplicaUser: "",
mysqlReplicaPassword: "",
mongoReplicaUser: "",
mongoReplicaPassword: "",
redisDB: 0,
connectionParams: "",
});
} else if (type !== "custom") {
const defaultUser =
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
form.setFieldsValue({
user: defaultUser,
database: "",
port: defaultPort,
useSSL: sslCapableType ? false : undefined,
sslMode: sslCapableType ? "preferred" : undefined,
sslCAPath: sslCapableType ? "" : undefined,
sslCertPath: sslCapableType ? "" : undefined,
sslKeyPath: sslCapableType ? "" : undefined,
useHttpTunnel: false,
httpTunnelHost: "",
httpTunnelPort: 8080,
httpTunnelUser: "",
httpTunnelPassword: "",
keepAliveEnabled: false,
keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
mysqlTopology: "single",
rocketmqTopology: "single",
mqttTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
mongoReadPreference: "primary",
mongoReplicaSet: "",
mongoAuthSource: "",
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
rocketmqHosts: [],
mqttHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",
redisSentinelPassword: "",
mongoHosts: [],
mysqlReplicaUser: "",
mysqlReplicaPassword: "",
mongoReplicaUser: "",
mongoReplicaPassword: "",
redisDB: 0,
connectionParams: "",
});
}
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
? t("connection.modal.jvm.unsupportedMode.banner")
: "";
const currentDriverType = resolveConnectionDriverType(dbType, customDriver);
const hasCurrentDriverType =
currentDriverType !== "" && currentDriverType !== "custom";
const currentDriverSnapshot = driverStatusMap[currentDriverType];
const currentDriverUnavailableReason =
hasCurrentDriverType &&
currentDriverSnapshot &&
!currentDriverSnapshot.connectable
? currentDriverSnapshot.message ||
t("connection.modal.driver.unavailableFallback", {
name: currentDriverSnapshot.name || dbType,
})
: "";
const currentDriverUpdateReason =
hasCurrentDriverType &&
currentDriverSnapshot?.connectable &&
currentDriverSnapshot.needsUpdate
? currentDriverSnapshot.message ||
currentDriverSnapshot.updateReason ||
t("connection.modal.driver.updateFallback", {
name: currentDriverSnapshot.name || dbType,
})
: "";
const driverStatusChecking =
hasCurrentDriverType && !driverStatusLoaded && step === 2;
const dbTypeGroups = useMemo(
() =>
buildConnectionTypeGroups(t).map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
icon: getDbIcon(item.key, undefined, 36),
})),
})),
[],
);
const dbTypes = getAllConnectionTypeCatalogItems();
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,
}}
>
{t("connection.modal.step1.sectionTitle")}
</div>
<div style={modalMutedTextStyle}>
{t("connection.modal.step1.sectionDescription")}
</div>
</div>
{typeSelectWarning && (
<Alert
type="warning"
showIcon
closable
message={t("connection.modal.typeWarning.unavailable", {
name: typeSelectWarning.driverName,
})}
description={
<Space size={8}>
<span>{typeSelectWarning.reason}</span>
<Button
type="link"
size="small"
onClick={() => onOpenDriverManager?.()}
>
{t("connection.modal.driver.installAction")}
</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 }}>
{getConnectionTypeHint(item.key, t)}
</Text>
</div>
</Card>
</Col>
))}
</Row>
</div>
</div>
</div>
);
const renderStep2 = () => (
<ConnectionModalStep2
{...{
activeConfigSection,
activeNetworkConfig,
buildRedisDatabaseList,
clearConnectionTestResultForChoice,
connectionConfigLayout,
createCustomDsnRule,
createUriAwareRequiredRule,
currentDriverSnapshot,
currentDriverUnavailableReason,
currentDriverUpdateReason,
customIconColor,
customIconType,
darkMode,
dbList,
dbType,
discoveringMembers,
form,
getConnectionOptionCardStyle,
handleCopyURI,
handleDiscoverMongoMembers,
handleGenerateURI,
handleJvmModeCardSelect,
handleJvmModeToggle,
handleParseURI,
handleSelectCertificateFile,
handleSelectDatabaseFile,
handleSelectSSHKeyFile,
initialValues,
isCustom,
isFileDb,
isJVM,
isKafka,
isMQTT,
isMySQLLike,
isOceanBaseOracle,
isRedis,
isRocketMQ,
isSSLType,
jvmDiagnosticEnabled,
jvmDiagnosticTransport,
jvmEnvironment,
jvmPreferredMode,
jvmSectionCardStyle,
kafkaTopology,
modalInnerSectionStyle,
modalMutedTextStyle,
mongoAuthMechanism,
mongoMembers,
mongoReadPreference,
mongoSrv,
mongoTopology,
mqttTopology,
mysqlTopology,
normalizeRedisDatabaseSelection,
normalizedJvmAllowedModes,
oceanBaseProtocol,
onOpenDriverManager,
primaryPasswordVisible,
proxyType,
redisDbList,
redisTopology,
renderChoiceCards,
renderConfigSectionCard,
renderJvmSectionHeader,
renderStoredSecretControls,
resolvedUriFeedbackMessage,
rocketmqTopology,
selectingCertificateField,
selectingDbFile,
selectingSSHKey,
setActiveConfigSection,
setActiveNetworkConfig,
setChoiceFieldValue,
setCustomIconColor,
setCustomIconType,
setDbType,
setMongoMembers,
setPrimaryPasswordVisible,
setRedisDbList,
setTestErrorLogOpen,
setTestResult,
setUriFeedback,
setUseHttpTunnel,
setUseProxy,
setUseSSH,
setUseSSL,
sslHintText,
sslMode,
supportsConnectionParams,
supportsSSLCAPath,
supportsSSLClientCertificate,
testResult,
tunnelSectionStyle,
unsupportedJvmModeMessage,
uriFeedback,
useHttpTunnel,
useProxy,
useSSH,
useSSL,
}}
/>
);
const getFooter = () => {
if (step === 1) {
return [
<Button key="cancel" onClick={onClose}>
{t("common.action.cancel")}
</Button>,
];
}
const isTestSuccess = testResult?.type === "success";
const hasTestError = !!testResult && !isTestSuccess;
const testFailureSummary = hasTestError
? summarizeConnectionTestFailureMessage(
resolvedTestResultMessage,
t("connection.status.failure"),
)
: "";
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)}>
{t("common.action.back")}
</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
? t("connection.status.success")
: t("connection.status.failure")}
</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)}
>
{t("connection.action.viewDetails")}
</Button>
)}
</div>
<Space size={8} style={{ flexShrink: 0 }}>
<Button
key="test"
loading={loading}
disabled={operationBlocked}
onClick={requestTest}
>
{t("connection.action.test")}
</Button>
<Button key="cancel" onClick={onClose}>
{t("common.action.cancel")}
</Button>
<Button
key="submit"
type="primary"
loading={loading}
disabled={operationBlocked}
onClick={handleOk}
>
{t("common.action.save")}
</Button>
</Space>
</div>
);
};
const getTitle = () => {
if (step === 1) {
return renderConnectionModalTitle(
<AppstoreOutlined />,
t("connection.modal.title.step1"),
t("connection.modal.description.step1"),
);
}
const typeName = dbTypes.find((t) => t.key === dbType)?.name || dbType;
return initialValues
? renderConnectionModalTitle(
<EditOutlined />,
t("connection.modal.title.edit"),
t("connection.modal.description.edit", { type: typeName }),
)
: renderConnectionModalTitle(
<LinkOutlined />,
t("connection.modal.title.create", { type: typeName }),
t("connection.modal.description.create"),
);
};
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 />,
t("connection.modal.failureDialog.title"),
t("connection.modal.failureDialog.description"),
)}
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)}>
{t("common.action.close")}
</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,
fontFamily: "var(--gn-font-mono)",
}}
>
{String(
resolvedTestResultMessage ||
t("connection.modal.failureDialog.emptyLog"),
)}
</pre>
</Modal>
</>
);
};
export default ConnectionModal;