From dcd80873f1e5f491ebac34bae90bffc5408578c9 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 28 Jul 2026 14:36:43 +0800 Subject: [PATCH 1/6] feat: remove MongoDB advanced options and standardize topology labels Drop advanced auth/TLS/direct-connection UI; keep topology + auth_source/seed hosts; normalize request params so legacy keys are not rewritten. --- .../base/src/locale/zh-CN/dmsDataSource.ts | 25 ++ .../components/Form/FormItem/index.tsx | 292 +++++++++++++++++- .../Form/SqlAuditFields/ConfirmSwitch.tsx | 20 +- .../components/Form/SqlAuditFields/index.tsx | 14 +- .../page/DataSource/components/Form/index.tsx | 159 +++++++--- .../DataSource/components/Form/index.type.ts | 3 + .../DataSource/hooks/useCheckConnectable.ts | 31 +- .../base/src/page/DataSource/tool/index.ts | 278 +++++++++++++++++ 8 files changed, 760 insertions(+), 62 deletions(-) diff --git a/packages/base/src/locale/zh-CN/dmsDataSource.ts b/packages/base/src/locale/zh-CN/dmsDataSource.ts index 1d01c5bc91..bc4515174f 100644 --- a/packages/base/src/locale/zh-CN/dmsDataSource.ts +++ b/packages/base/src/locale/zh-CN/dmsDataSource.ts @@ -50,6 +50,9 @@ export default { name: '数据源名称', describe: '数据源描述', type: '数据源类型', + connectionMode: '连接模式', + connectionModeStandalone: '单实例', + connectionModeCluster: 'Cluster', ip: '数据源地址', ipTips: '数据源IP或域名', port: '数据源端口', @@ -104,6 +107,28 @@ export default { lineNumberLimit: '回滚行数限制', lineNumberLimitTips: '当预计影响行数超过指定值则不回滚', dataSourceConnectError: '数据源连通性测试失败', + mongoTopology: 'MongoDB 形态', + mongoTopologyTips: + '按 MongoDB 部署拓扑选择(Standalone / Replica set / Sharded cluster);选错会导致测连失败。', + mongoTopologySingle: '单机', + mongoTopologyReplicaSet: '副本集', + mongoTopologyShard: '分片集群', + mongoSingleTips: + '填写单个 standalone 实例地址与端口;无需副本集名称与成员地址', + mongoReplicaSetTips: + '在「副本集成员地址」填写多个 host:port;基础地址取首个成员;需填副本集名称', + mongoShardTips: + '只填单个 mongos 地址与端口;勿填 configsvr、shard 数据节点或副本集多成员', + mongoAuthSource: '认证数据库', + mongoAuthSourceTips: 'MongoDB 认证库名,通常为 admin', + mongoReplicaSet: '副本集名称', + mongoReplicaSetNameTips: '仅副本集形态需要填写', + mongoSeedHosts: '副本集成员地址', + mongoSeedHostsTips: '多个成员使用英文逗号或换行分隔', + mongoSeedHostsPlaceholder: + '例如:192.0.2.10:27017,192.0.2.11:27017,192.0.2.12:27017', + mongoSeedHostsRule: + '请填写 host:port 格式的成员地址,多个成员用英文逗号或换行分隔', returnModify: '返回修改', continueSubmit: '继续提交' }, diff --git a/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx b/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx index 6f83db2d0a..f95403d5fe 100644 --- a/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx +++ b/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx @@ -1,7 +1,7 @@ import { useBoolean } from 'ahooks'; -import { useEffect, useState, useContext } from 'react'; +import { useEffect, useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FormInstance } from 'antd'; +import { Form, FormInstance } from 'antd'; import EmitterKey from '../../../../../data/EmitterKey'; import EventEmitter from '../../../../../utils/EventEmitter'; import { DataSourceFormField } from '../index.type'; @@ -12,13 +12,31 @@ import { EmptyBox, TestDatabaseConnectButton } from '@actiontech/dms-kit'; -import { FormItemLabel, FormItemNoLabel } from '@actiontech/dms-kit'; +import { + CustomLabelContent, + FormItemLabel, + FormItemNoLabel +} from '@actiontech/dms-kit'; import { validatorPort } from '@actiontech/dms-kit'; import { AutoCreatedFormItemByApi, BackendFormItemParams } from '@actiontech/shared'; import { DataSourceFormContext } from '../../../context'; +import { + DEFAULT_MONGO_TOPOLOGY, + DEFAULT_REDIS_CONNECTION_MODE, + isMongoLegacyRemovedParam, + isRedisDbType, + MONGODB_AUTH_SOURCE_PARAM, + MONGODB_MAIN_PARAMS, + MONGODB_REPLICA_SET_PARAM, + MONGODB_SEED_HOSTS_PARAM, + MONGODB_TOPOLOGY_PARAM, + normalizeMongoSeedHosts, + validateMongoSeedHosts +} from '../../../tool'; + const DatabaseFormItem: React.FC<{ form: FormInstance; isUpdate?: boolean; @@ -26,14 +44,136 @@ const DatabaseFormItem: React.FC<{ generateDriverSelectOptions?: () => JSX.Element[]; updateDriverListLoading: boolean; currentAsyncParams?: BackendFormItemParams[]; + databaseType?: string; isExternalInstance?: boolean; }> = (props) => { const { t } = useTranslation(); const formContext = useContext(DataSourceFormContext); + const databaseType = Form.useWatch('type', props.form); + const connectionMode = Form.useWatch('connectionMode', props.form); + const isRedis = isRedisDbType(databaseType); const [ hideConnectionInfo, { setFalse: setConnectionInfoShow, setTrue: setConnectionInfoHide } ] = useBoolean(true); + const isMongoDB = props.databaseType?.toLowerCase() === 'mongodb'; + const mongoTopology = Form.useWatch('mongoTopology', props.form); + const mongodbAsyncParams = useMemo(() => { + return props.currentAsyncParams ?? []; + }, [props.currentAsyncParams]); + const seedHostsParam = useMemo(() => { + return mongodbAsyncParams.find( + (item) => item.key === MONGODB_SEED_HOSTS_PARAM + ); + }, [mongodbAsyncParams]); + const replicaSetParam = useMemo(() => { + return mongodbAsyncParams.find( + (item) => item.key === MONGODB_REPLICA_SET_PARAM + ); + }, [mongodbAsyncParams]); + + const mongoParamDescMap = useMemo>( + () => ({ + [MONGODB_AUTH_SOURCE_PARAM]: `${t( + 'dmsDataSource.dataSourceForm.mongoAuthSource' + )}(${t('dmsDataSource.dataSourceForm.mongoAuthSourceTips')})`, + [MONGODB_REPLICA_SET_PARAM]: `${t( + 'dmsDataSource.dataSourceForm.mongoReplicaSet' + )}(${t('dmsDataSource.dataSourceForm.mongoReplicaSetNameTips')})` + }), + [t] + ); + + const withMongoChineseDesc = ( + items: BackendFormItemParams[] + ): BackendFormItemParams[] => + items.map((item) => { + const key = item.key ?? ''; + const desc = mongoParamDescMap[key]; + return desc ? { ...item, desc } : item; + }); + + const mongoHandledKeys = useMemo( + () => + new Set([ + ...MONGODB_MAIN_PARAMS, + MONGODB_REPLICA_SET_PARAM, + MONGODB_SEED_HOSTS_PARAM, + MONGODB_TOPOLOGY_PARAM, + 'auth_mechanism', + 'tls', + 'tls_skip_verify', + 'direct_connection' + ]), + [] + ); + + const mongoMainParams = useMemo(() => { + if (!isMongoDB) { + return []; + } + return withMongoChineseDesc( + mongodbAsyncParams.filter((item) => + (MONGODB_MAIN_PARAMS as readonly string[]).includes(item.key ?? '') + ) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMongoDB, mongodbAsyncParams, mongoParamDescMap]); + + const autoCreatedAsyncParams = useMemo(() => { + if (!isMongoDB) { + return mongodbAsyncParams; + } + return mongodbAsyncParams.filter( + (item) => + !mongoHandledKeys.has(item.key ?? '') && + !isMongoLegacyRemovedParam(item.key) + ); + }, [isMongoDB, mongodbAsyncParams, mongoHandledKeys]); + + const mergeMongoTopologyParams = ( + topology: DataSourceFormField['mongoTopology'], + paramsValue: Record + ) => { + const nextParams: Record = { + ...paramsValue, + [MONGODB_SEED_HOSTS_PARAM]: + topology === 'replicaSet' ? paramsValue[MONGODB_SEED_HOSTS_PARAM] : '' + }; + + if (topology === 'single' || topology === 'shard') { + nextParams.replica_set = ''; + } + + return nextParams; + }; + const changeMongoTopology = (value: string | number) => { + const nextTopology = value as DataSourceFormField['mongoTopology']; + const paramsValue = (props.form.getFieldValue('params') ?? {}) as Record< + string, + string | boolean | undefined + >; + + props.form.setFieldsValue({ + mongoTopology: nextTopology, + params: mergeMongoTopologyParams(nextTopology, paramsValue) + }); + }; + const formatSeedHosts = () => { + const paramsValue = (props.form.getFieldValue('params') ?? {}) as Record< + string, + string | boolean | undefined + >; + const seedHosts = paramsValue[MONGODB_SEED_HOSTS_PARAM]; + if (typeof seedHosts === 'string') { + props.form.setFieldsValue({ + params: { + ...paramsValue, + [MONGODB_SEED_HOSTS_PARAM]: normalizeMongoSeedHosts(seedHosts) + } + }); + } + }; const [needUpdatePassword, setNeedUpdatePassword] = useState(false); const changeNeedUpdatePassword = (check: boolean) => { setNeedUpdatePassword(check); @@ -62,6 +202,7 @@ const DatabaseFormItem: React.FC<{ }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return ( <> + + + + + - 0}> + + + } + name="mongoTopology" + initialValue={DEFAULT_MONGO_TOPOLOGY} + > + + + +
+ {mongoTopology === 'replicaSet' + ? t('dmsDataSource.dataSourceForm.mongoReplicaSetTips') + : mongoTopology === 'shard' + ? t('dmsDataSource.dataSourceForm.mongoShardTips') + : t('dmsDataSource.dataSourceForm.mongoSingleTips')} +
+
+
+ + 0}> + + + + + + + + + + } + name={['params', MONGODB_SEED_HOSTS_PARAM]} + rules={[ + { + required: true, + message: t('common.form.rule.require', { + name: t('dmsDataSource.dataSourceForm.mongoSeedHosts') + }) + }, + { + validator: (_, value) => { + if (validateMongoSeedHosts(value)) { + return Promise.resolve(); + } + return Promise.reject( + new Error( + t('dmsDataSource.dataSourceForm.mongoSeedHostsRule') + ) + ); + } + } + ]} + > + + + + + 0}> diff --git a/packages/base/src/page/DataSource/components/Form/SqlAuditFields/ConfirmSwitch.tsx b/packages/base/src/page/DataSource/components/Form/SqlAuditFields/ConfirmSwitch.tsx index 2bf672bd04..8a8f7087dc 100644 --- a/packages/base/src/page/DataSource/components/Form/SqlAuditFields/ConfirmSwitch.tsx +++ b/packages/base/src/page/DataSource/components/Form/SqlAuditFields/ConfirmSwitch.tsx @@ -26,6 +26,11 @@ const ConfirmSwitch: React.FC = ({ onChange?.(value); } }; + const onTriggerClick = () => { + if (checked) { + setAuditRequiredPopupVisible(true); + } + }; const onInnerConfirm = () => { onChange?.(false); onConfirm?.(); @@ -38,11 +43,16 @@ const ConfirmSwitch: React.FC = ({ onOpenChange={onOpenChange} onConfirm={onInnerConfirm} > - + + + ); }; diff --git a/packages/base/src/page/DataSource/components/Form/SqlAuditFields/index.tsx b/packages/base/src/page/DataSource/components/Form/SqlAuditFields/index.tsx index 417118f15d..7c4f21f93b 100644 --- a/packages/base/src/page/DataSource/components/Form/SqlAuditFields/index.tsx +++ b/packages/base/src/page/DataSource/components/Form/SqlAuditFields/index.tsx @@ -45,8 +45,8 @@ const SqlAuditFields: React.FC = ({ }) => { const { t } = useTranslation(); const form = Form.useFormInstance(); - const needSqlAuditService = Form.useWatch('needSqlAuditService'); - const needAuditForSqlQuery = Form.useWatch('needAuditForSqlQuery'); + const needSqlAuditService = Form.useWatch('needSqlAuditService', form); + const needAuditForSqlQuery = Form.useWatch('needAuditForSqlQuery', form); const changeRuleTemplate = ( value: string, option: BaseOptionType, @@ -97,7 +97,7 @@ const SqlAuditFields: React.FC = ({ className="has-required-style has-label-tip" rules={[ { - required: true, + required: !!needSqlAuditService, message: t('common.form.placeholder.select', { name: t('dmsDataSource.dataSourceForm.ruleTemplate') }) @@ -136,7 +136,7 @@ const SqlAuditFields: React.FC = ({ className="has-required-style has-label-tip" rules={[ { - required: true, + required: !!needSqlAuditService, message: t('common.form.placeholder.select', { name: t( 'dmsDataSource.dataSourceForm.dataExportAuditRuleTemplate' @@ -196,7 +196,8 @@ const SqlAuditFields: React.FC = ({ name="workbenchTemplateName" rules={[ { - required: true + required: + !!needSqlAuditService && !!needAuditForSqlQuery } ]} className="has-required-style" @@ -230,7 +231,8 @@ const SqlAuditFields: React.FC = ({ name="allowQueryWhenLessThanAuditLevel" rules={[ { - required: true, + required: + !!needSqlAuditService && !!needAuditForSqlQuery, message: t('common.form.placeholder.select', { name: t( 'dmsDataSource.dataSourceForm.allowQueryWhenLessThanAuditLevel' diff --git a/packages/base/src/page/DataSource/components/Form/index.tsx b/packages/base/src/page/DataSource/components/Form/index.tsx index d771121761..ba43dac477 100644 --- a/packages/base/src/page/DataSource/components/Form/index.tsx +++ b/packages/base/src/page/DataSource/components/Form/index.tsx @@ -35,7 +35,21 @@ import { import { nameRule } from '@actiontech/dms-kit'; import DatabaseFormItem from './FormItem'; import MaintenanceTimePicker from './MaintenanceTimePicker'; -import { turnDataSourceAsyncFormToCommon } from '../../tool'; +import { + DEFAULT_REDIS_CONNECTION_MODE, + filterMongoTopologyParam, + filterRedisConnectionModeParam, + getMongoTopologyFromParams, + getRedisConnectionModeFromParams, + isMongoDbType, + isRedisDbType, + mergeMongoTopologyIntoParams, + mergeRedisConnectionModeIntoParams, + MONGODB_SEED_HOSTS_PARAM, + normalizeMongoParams, + normalizeMongoRequestParams, + turnDataSourceAsyncFormToCommon +} from '../../tool'; import { useAsyncParams, BackendFormItemParams } from '@actiontech/shared'; import { useRequest } from 'ahooks'; import rule_template from '@actiontech/shared/lib/api/sqle/service/rule_template'; @@ -79,6 +93,36 @@ const DataSourceForm: React.FC = (props) => { const { projectID } = useCurrentProject(); const project = Form.useWatch('project', props.form); const enableBackup = Form.useWatch('enableBackup', props.form); + const changeAuditEnabled = useCallback( + (check: boolean) => { + if (!check) { + props.form.setFieldsValue({ + needSqlAuditService: false, + ruleTemplateId: undefined, + ruleTemplateName: undefined, + dataExportRuleTemplateId: undefined, + dataExportRuleTemplateName: undefined, + needAuditForSqlQuery: false, + workbenchTemplateId: undefined, + workbenchTemplateName: undefined, + allowQueryWhenLessThanAuditLevel: undefined, + allowExecuteNonDqlInWorkflow: undefined + }); + } else { + if (props.defaultData) { + props.form.setFieldsValue({ + allowQueryWhenLessThanAuditLevel: + props.defaultData.sqle_config?.sql_query_config + ?.allow_query_when_less_than_audit_level, + allowExecuteNonDqlInWorkflow: + !!props.defaultData.sqle_config?.sql_query_config + ?.workflow_exec_enabled + }); + } + } + }, + [props.defaultData, props.form] + ); const getBackupSupportStatus = useCallback((value: string) => { system .getSystemModuleStatus({ @@ -94,6 +138,10 @@ const DataSourceForm: React.FC = (props) => { const databaseTypeChange = useCallback( (value: string) => { setDatabaseType(value); + props.form.setFieldValue( + 'connectionMode', + isRedisDbType(value) ? DEFAULT_REDIS_CONNECTION_MODE : undefined + ); // #if [sqle] props.form.resetFields([ 'ruleTemplateName', @@ -105,12 +153,15 @@ const DataSourceForm: React.FC = (props) => { 'allowExecuteNonDqlInWorkflow', 'sqlWorkbenchMaintenanceTime' ]); + if (value.toLowerCase() === 'mongodb') { + changeAuditEnabled(false); + } // #endif // #if [sqle && ee] getBackupSupportStatus(value); // #endif }, - [props.form, getBackupSupportStatus] + [props.form, getBackupSupportStatus, changeAuditEnabled] ); const { generateFormValueByParams, mergeFromValueIntoParams } = useAsyncParams(); @@ -148,25 +199,11 @@ const DataSourceForm: React.FC = (props) => { }; }); }, [ruleTemplateList, globalRuleTemplateList, databaseType]); - const changeAuditEnabled = (check: boolean) => { - if (!check) { - props.form.setFieldsValue({ - allowQueryWhenLessThanAuditLevel: undefined, - allowExecuteNonDqlInWorkflow: undefined - }); - } else { - if (props.defaultData) { - props.form.setFieldsValue({ - allowQueryWhenLessThanAuditLevel: - props.defaultData.sqle_config?.sql_query_config - ?.allow_query_when_less_than_audit_level, - allowExecuteNonDqlInWorkflow: - !!props.defaultData.sqle_config?.sql_query_config - ?.workflow_exec_enabled - }); - } + useEffect(() => { + if (databaseType.toLowerCase() === 'mongodb') { + changeAuditEnabled(false); } - }; + }, [databaseType, changeAuditEnabled]); // #endif const params = useMemo(() => { @@ -177,7 +214,26 @@ const DataSourceForm: React.FC = (props) => { if (!temp) { return []; } - return turnDataSourceAsyncFormToCommon(temp.params ?? []); + const driverParams = filterMongoTopologyParam( + filterRedisConnectionModeParam( + turnDataSourceAsyncFormToCommon(temp.params ?? []) + ) + ); + if ( + databaseType.toLowerCase() === 'mongodb' && + !driverParams.some((item) => item.key === MONGODB_SEED_HOSTS_PARAM) + ) { + return [ + ...driverParams, + { + desc: 'MongoDB seed hosts', + key: MONGODB_SEED_HOSTS_PARAM, + type: 'string', + value: '' + } + ]; + } + return driverParams; }, [databaseType, driverMeta]); useEffect(() => { if (!!props.defaultData) { @@ -185,12 +241,24 @@ const DataSourceForm: React.FC = (props) => { name: props.defaultData.name, describe: props.defaultData.desc, type: props.defaultData.db_type, + connectionMode: isRedisDbType(props.defaultData.db_type) + ? getRedisConnectionModeFromParams( + props.defaultData.additional_params + ) + : undefined, + mongoTopology: isMongoDbType(props.defaultData.db_type) + ? getMongoTopologyFromParams(props.defaultData.additional_params) + : undefined, ip: props.defaultData.host, port: Number.parseInt(props.defaultData.port ?? ''), user: props.defaultData.user, params: generateFormValueByParams( - turnDataSourceAsyncFormToCommon( - props.defaultData.additional_params ?? [] + filterMongoTopologyParam( + filterRedisConnectionModeParam( + turnDataSourceAsyncFormToCommon( + props.defaultData.additional_params ?? [] + ) + ) ) ), maintenanceTime: props.defaultData.maintenance_times?.map((item) => ({ @@ -262,15 +330,25 @@ const DataSourceForm: React.FC = (props) => { }, [props.form]); const submit = useCallback(async () => { const values = props.form.getFieldsValue(); + values.params = normalizeMongoParams(values.params); if (values.params) { - values.asyncParams = mergeFromValueIntoParams(values.params, params).map( - (v) => ({ + values.asyncParams = normalizeMongoRequestParams( + mergeFromValueIntoParams(values.params, params).map((v) => ({ name: v.key, value: v.value - }) + })) ); delete values.params; } + values.asyncParams = mergeMongoTopologyIntoParams( + mergeRedisConnectionModeIntoParams( + values.asyncParams, + values.type, + values.connectionMode + ), + values.type, + values.mongoTopology + ); props.submit(values).then(() => { closeModal(); }); @@ -398,6 +476,7 @@ const DataSourceForm: React.FC = (props) => { generateDriverSelectOptions={generateDriverSelectOptions} updateDriverListLoading={updateDriverListLoading} currentAsyncParams={params} + databaseType={databaseType} isExternalInstance={isExternalInstance} /> = (props) => { {/* #if [sqle] */} - - - + {databaseType.toLowerCase() !== 'mongodb' && ( + + + + )} {/* #endif */} {/* #if [sqle && ee] */} diff --git a/packages/base/src/page/DataSource/components/Form/index.type.ts b/packages/base/src/page/DataSource/components/Form/index.type.ts index eba0e903a3..d4c98df01c 100644 --- a/packages/base/src/page/DataSource/components/Form/index.type.ts +++ b/packages/base/src/page/DataSource/components/Form/index.type.ts @@ -6,11 +6,13 @@ import { import { MaintenanceTimeValue } from './MaintenanceTimePicker'; import { SQLQueryConfigAllowQueryWhenLessThanAuditLevelEnum } from '@actiontech/shared/lib/api/base/service/common.enum'; import { IListDBServiceV2 } from '@actiontech/shared/lib/api/base/service/common'; +import type { MongoTopology, RedisConnectionMode } from '../../tool'; export type DataSourceFormField = { name: string; describe?: string; type: string; + connectionMode?: RedisConnectionMode; ip: string; port: number; user: string; @@ -31,6 +33,7 @@ export type DataSourceFormField = { dataExportRuleTemplateName?: string; params?: BackendFormValues; asyncParams?: BackendFormRequestParams[]; + mongoTopology?: MongoTopology; needUpdatePassword?: boolean; enableBackup?: boolean; backupMaxRows?: number; diff --git a/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts b/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts index 7916055d64..0281533818 100644 --- a/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts +++ b/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts @@ -9,6 +9,12 @@ import { getDBServiceConnectableErrorMessage, getDbServiceIsConnectbale } from '../../../utils/common'; +import { + mergeMongoTopologyIntoParams, + mergeRedisConnectionModeIntoParams, + normalizeMongoParams, + normalizeMongoRequestParams +} from '../tool'; const useCheckConnectable = (form: FormInstance) => { const projectID = Form.useWatch('project', form); @@ -28,17 +34,32 @@ const useCheckConnectable = (form: FormInstance) => { 'port', 'user', 'type', - 'params' + 'params', + 'connectionMode', + 'mongoTopology' ]); + values.params = normalizeMongoParams(values.params); + if (values.params && currentAsyncParams) { - values.asyncParams = mergeFromValueIntoParams( - values.params, - currentAsyncParams - ).map((v) => ({ name: v.key, value: v.value })); + values.asyncParams = normalizeMongoRequestParams( + mergeFromValueIntoParams(values.params, currentAsyncParams).map( + (v) => ({ name: v.key, value: v.value }) + ) + ); delete values.params; } + values.asyncParams = mergeMongoTopologyIntoParams( + mergeRedisConnectionModeIntoParams( + values.asyncParams, + values.type, + values.connectionMode + ), + values.type, + values.mongoTopology + ); + setLoadingTrue(); return DmsApi.DBServiceService.CheckDBServiceIsConnectable({ db_service: { diff --git a/packages/base/src/page/DataSource/tool/index.ts b/packages/base/src/page/DataSource/tool/index.ts index 767773a55c..adc2b60280 100644 --- a/packages/base/src/page/DataSource/tool/index.ts +++ b/packages/base/src/page/DataSource/tool/index.ts @@ -37,3 +37,281 @@ export const turnCommonToDataSourceParams = < value: item.value })); }; + +export const REDIS_CONNECTION_MODE_PARAM_NAME = 'connection_mode'; + +export type RedisConnectionMode = 'standalone' | 'cluster'; + +export const DEFAULT_REDIS_CONNECTION_MODE: RedisConnectionMode = 'standalone'; + +export const isRedisDbType = (dbType?: string): boolean => { + return dbType?.toLowerCase() === 'redis'; +}; + +export const getRedisConnectionModeFromParams = < + T extends { + name?: string; + key?: string; + value?: string; + }[] +>( + params?: T +): RedisConnectionMode => { + const value = params?.find( + (item) => + item.name === REDIS_CONNECTION_MODE_PARAM_NAME || + item.key === REDIS_CONNECTION_MODE_PARAM_NAME + )?.value; + + return value === 'cluster' ? 'cluster' : DEFAULT_REDIS_CONNECTION_MODE; +}; + +export const filterRedisConnectionModeParam = < + T extends { + name?: string; + key?: string; + }[] +>( + params: T +): T => { + return params.filter( + (item) => + item.name !== REDIS_CONNECTION_MODE_PARAM_NAME && + item.key !== REDIS_CONNECTION_MODE_PARAM_NAME + ) as T; +}; + +export const mergeRedisConnectionModeIntoParams = < + T extends { + name?: string; + value?: string; + }[] +>( + params: T | undefined, + dbType?: string, + connectionMode?: RedisConnectionMode +): T => { + const nextParams = filterRedisConnectionModeParam(params ?? []) as T; + + if (!isRedisDbType(dbType)) { + return nextParams; + } + + return [ + ...nextParams, + { + name: REDIS_CONNECTION_MODE_PARAM_NAME, + value: connectionMode ?? DEFAULT_REDIS_CONNECTION_MODE + } + ] as T; +}; + +export const MONGODB_SEED_HOSTS_PARAM = 'seed_hosts'; +export const MONGODB_AUTH_SOURCE_PARAM = 'auth_source'; +export const MONGODB_REPLICA_SET_PARAM = 'replica_set'; +export const MONGODB_TOPOLOGY_PARAM = 'topology'; + +export type MongoTopology = 'single' | 'replicaSet' | 'shard'; + +export const DEFAULT_MONGO_TOPOLOGY: MongoTopology = 'single'; + +export const isMongoDbType = (dbType?: string): boolean => { + return dbType?.toLowerCase() === 'mongodb'; +}; + +export const isMongoTopology = (value?: string): value is MongoTopology => + value === 'single' || value === 'replicaSet' || value === 'shard'; + +const getParamValueByNameOrKey = < + T extends { + name?: string; + key?: string; + value?: string; + }[] +>( + params: T | undefined, + paramName: string +) => + params?.find((item) => item.name === paramName || item.key === paramName) + ?.value; + +/** + * 优先读 additional_params.topology;存量无键时: + * 有 seed_hosts 或非空 replica_set → replicaSet,否则 single。 + * 旧分片无键无法识别,回落 single(需用户改选分片集群后保存补齐)。 + */ +export const getMongoTopologyFromParams = < + T extends { + name?: string; + key?: string; + value?: string; + }[] +>( + params?: T +): MongoTopology => { + const topology = getParamValueByNameOrKey(params, MONGODB_TOPOLOGY_PARAM); + if (isMongoTopology(topology)) { + return topology; + } + + const seedHosts = getParamValueByNameOrKey(params, MONGODB_SEED_HOSTS_PARAM); + const replicaSet = getParamValueByNameOrKey( + params, + MONGODB_REPLICA_SET_PARAM + ); + if ( + (typeof seedHosts === 'string' && !!seedHosts.trim()) || + (typeof replicaSet === 'string' && !!replicaSet.trim()) + ) { + return 'replicaSet'; + } + + return DEFAULT_MONGO_TOPOLOGY; +}; + +export const filterMongoTopologyParam = < + T extends { + name?: string; + key?: string; + }[] +>( + params: T +): T => { + return params.filter( + (item) => + item.name !== MONGODB_TOPOLOGY_PARAM && + item.key !== MONGODB_TOPOLOGY_PARAM + ) as T; +}; + +export const mergeMongoTopologyIntoParams = < + T extends { + name?: string; + value?: string; + }[] +>( + params: T | undefined, + dbType?: string, + topology?: MongoTopology +): T => { + const nextParams = filterMongoTopologyParam(params ?? []) as T; + + if (!isMongoDbType(dbType)) { + return nextParams; + } + + return [ + ...nextParams, + { + name: MONGODB_TOPOLOGY_PARAM, + value: topology ?? DEFAULT_MONGO_TOPOLOGY + } + ] as T; +}; + +/** Mongo 主表单常驻参数(不含拓扑条件字段) */ +export const MONGODB_MAIN_PARAMS = [MONGODB_AUTH_SOURCE_PARAM] as const; + +/** 仅副本集形态展示 */ +export const MONGODB_REPLICA_PARAMS = [ + MONGODB_REPLICA_SET_PARAM, + MONGODB_SEED_HOSTS_PARAM +] as const; + +/** + * 后端已删除、UI 不再展示的遗留 additional_params。 + * 编辑存量数据源时仍可能出现在表单初始值中,提交与自动表单项均剥离。 + */ +const MONGODB_LEGACY_REMOVED_PARAMS = new Set([ + 'auth_mechanism', + 'tls', + 'tls_skip_verify', + 'direct_connection' +]); + +export const isMongoLegacyRemovedParam = (key?: string) => + !!key && MONGODB_LEGACY_REMOVED_PARAMS.has(key); + +const splitMongoSeedHosts = (value?: string) => + (value ?? '') + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + +const isValidMongoPort = (value: string) => { + if (!/^\d+$/.test(value)) { + return false; + } + const port = Number(value); + return port > 0 && port <= 65535; +}; + +export const normalizeMongoSeedHosts = (value?: string) => { + let latestHost = ''; + return splitMongoSeedHosts(value) + .map((item) => { + if (/^\d+$/.test(item) && latestHost) { + return `${latestHost}:${item}`; + } + + const splitIndex = item.lastIndexOf(':'); + if (splitIndex > 0) { + latestHost = item.slice(0, splitIndex).trim(); + } + + return item; + }) + .join(','); +}; + +export const validateMongoSeedHosts = (value?: string) => { + const normalizedValue = normalizeMongoSeedHosts(value); + const seedHosts = splitMongoSeedHosts(normalizedValue); + + if (seedHosts.length === 0) { + return false; + } + + return seedHosts.every((item) => { + const splitIndex = item.lastIndexOf(':'); + if (splitIndex <= 0 || splitIndex === item.length - 1) { + return false; + } + + const host = item.slice(0, splitIndex).trim(); + const port = item.slice(splitIndex + 1).trim(); + return !!host && isValidMongoPort(port); + }); +}; + +export const normalizeMongoParams = >( + params?: T +) => { + if ( + !params || + typeof params[MONGODB_SEED_HOSTS_PARAM] !== 'string' || + !params[MONGODB_SEED_HOSTS_PARAM] + ) { + return params; + } + + return { + ...params, + [MONGODB_SEED_HOSTS_PARAM]: normalizeMongoSeedHosts( + params[MONGODB_SEED_HOSTS_PARAM] as string + ) + }; +}; + +export const normalizeMongoRequestParams = < + T extends { name?: string; value?: string } +>( + params: T[] +) => { + return params.filter((item) => { + if (isMongoLegacyRemovedParam(item.name)) { + return false; + } + return item.name !== MONGODB_SEED_HOSTS_PARAM || !!item.value; + }); +}; From 6facef944eab75c67dd2dfbfbf5e25b3c54753fa Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 28 Jul 2026 14:36:43 +0800 Subject: [PATCH 2/6] test: cover MongoDB topology form and legacy param normalize Align AddDataSource and tool unit tests with removed advanced options and topology label changes. --- .../components/AddDataSource/index.test.tsx | 179 ++++++++++++++++++ .../src/page/DataSource/tool/index.test.ts | 41 +++- 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx b/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx index 8b717a3ec3..3d6ed14b39 100644 --- a/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx +++ b/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx @@ -35,6 +35,39 @@ describe('page/DataSource/AddDataSource', () => { return baseSuperRender(); }; + const mockMongoDriverOption = () => { + baseMockApi.global.getListDBServiceDriverOption().mockImplementation(() => + createSpySuccessResponse({ + data: [ + { + db_type: 'MongoDB', + logo_path: 'logo_path_mock', + params: [ + { + description: 'MongoDB authentication database', + name: 'auth_source', + type: 'string', + value: 'admin' + }, + { + description: 'Replica set name', + name: 'replica_set', + type: 'string', + value: '' + }, + { + description: 'MongoDB seed hosts', + name: 'seed_hosts', + type: 'string', + value: '' + } + ] + } + ] + }) + ); + }; + beforeEach(() => { jest.useFakeTimers(); (useNavigate as jest.Mock).mockImplementation(() => navigateSpy); @@ -237,6 +270,152 @@ describe('page/DataSource/AddDataSource', () => { expect(screen.getByText('添加数据源')).toBeInTheDocument(); }); + it.each([ + { + topology: '单机', + name: 'mongo-single-source', + host: '192.0.2.10', + port: '27017', + expectedParams: [ + { name: 'auth_source', value: 'admin' }, + { name: 'replica_set', value: '' } + ] + }, + { + topology: '副本集', + name: 'mongo-replica-source', + host: '192.0.2.10', + port: '27017', + replicaSet: 'rs0', + seedHosts: '192.0.2.10:27017,27018,27019', + expectedParams: [ + { name: 'auth_source', value: 'admin' }, + { name: 'replica_set', value: 'rs0' }, + { + name: 'seed_hosts', + value: '192.0.2.10:27017,192.0.2.10:27018,192.0.2.10:27019' + } + ] + }, + { + topology: '分片集群', + name: 'mongo-shard-source', + host: '192.0.2.20', + port: '27017', + expectedParams: [ + { name: 'auth_source', value: 'admin' }, + { name: 'replica_set', value: '' } + ] + } + ])( + 'submits MongoDB $topology to v2 create API with expected params', + async ({ + topology, + name, + host, + port, + replicaSet, + seedHosts, + expectedParams + }) => { + mockMongoDriverOption(); + const { baseElement } = customRender(); + await act(async () => jest.advanceTimersByTime(9300)); + + fireEvent.change(getBySelector('#name', baseElement), { + target: { value: name } + }); + await act(async () => jest.advanceTimersByTime(300)); + + fireEvent.mouseDown(getBySelector('#type', baseElement)); + await act(async () => jest.advanceTimersByTime(300)); + fireEvent.click(getBySelector('span[title="MongoDB"]', baseElement)); + await act(async () => jest.advanceTimersByTime(3000)); + + if (topology !== '单机') { + fireEvent.mouseDown(getBySelector('#mongoTopology', baseElement)); + await act(async () => jest.advanceTimersByTime(300)); + fireEvent.click(screen.getByText(topology)); + await act(async () => jest.advanceTimersByTime(300)); + } + + fireEvent.change(getBySelector('#ip', baseElement), { + target: { value: host } + }); + fireEvent.change(getBySelector('#port', baseElement), { + target: { value: port } + }); + fireEvent.change(getBySelector('#user', baseElement), { + target: { value: 'mongo' } + }); + fireEvent.change(getBySelector('#password', baseElement), { + target: { value: 'Mongo@123' } + }); + fireEvent.change(getBySelector('#params_auth_source', baseElement), { + target: { value: 'admin' } + }); + + if (replicaSet) { + fireEvent.change(getBySelector('#params_replica_set', baseElement), { + target: { value: replicaSet } + }); + } + if (seedHosts) { + fireEvent.change(getBySelector('#params_seed_hosts', baseElement), { + target: { value: seedHosts } + }); + fireEvent.blur(getBySelector('#params_seed_hosts', baseElement)); + } else { + expect( + baseElement.querySelector('#params_seed_hosts') + ).not.toBeInTheDocument(); + } + await act(async () => jest.advanceTimersByTime(300)); + + fireEvent.click(getBySelector('.editable-select-trigger', baseElement)); + await act(async () => jest.advanceTimersByTime(0)); + fireEvent.click(getAllBySelector('.ant-dropdown-menu-item')[0]); + await act(async () => jest.advanceTimersByTime(0)); + + await act(async () => { + fireEvent.click(screen.getByText('提 交')); + }); + expect(checkDbServiceIsConnectableSpy).toHaveBeenCalledTimes(1); + await act(async () => jest.advanceTimersByTime(3000)); + + expect(requestAddDBServiceSpy).toHaveBeenCalledWith({ + db_service: { + additional_params: expectedParams, + db_type: 'MongoDB', + desc: undefined, + environment_tag_uid: '1', + host, + maintenance_times: [], + name, + password: 'Mongo@123', + port, + sqle_config: { + audit_enabled: false, + data_export_rule_template_id: undefined, + data_export_rule_template_name: undefined, + rule_template_id: undefined, + rule_template_name: undefined, + sql_query_config: { + allow_query_when_less_than_audit_level: undefined, + audit_enabled: undefined, + maintenance_times: [], + workflow_exec_enabled: undefined + } + }, + user: 'mongo', + enable_backup: false, + backup_max_rows: undefined + }, + project_uid: projectID + }); + } + ); + it('render conenctable modal when current service can not connect', async () => { checkDbServiceIsConnectableSpy.mockImplementation(() => createSpySuccessResponse({ diff --git a/packages/base/src/page/DataSource/tool/index.test.ts b/packages/base/src/page/DataSource/tool/index.test.ts index fec618ef7d..d640cbc3c2 100644 --- a/packages/base/src/page/DataSource/tool/index.test.ts +++ b/packages/base/src/page/DataSource/tool/index.test.ts @@ -1,6 +1,10 @@ import { + normalizeMongoParams, + normalizeMongoRequestParams, + normalizeMongoSeedHosts, turnCommonToDataSourceParams, - turnDataSourceAsyncFormToCommon + turnDataSourceAsyncFormToCommon, + validateMongoSeedHosts } from '.'; describe('datasource tool', () => { @@ -39,4 +43,39 @@ describe('datasource tool', () => { } ]); }); + + it('should normalize mongodb seed hosts dynamic param', () => { + expect(normalizeMongoSeedHosts('192.0.2.10:27017,27018,27019')).toBe( + '192.0.2.10:27017,192.0.2.10:27018,192.0.2.10:27019' + ); + expect( + normalizeMongoParams({ + seed_hosts: '192.0.2.10:27017\n192.0.2.11:27017', + replica_set: 'rs0' + }) + ).toEqual({ + seed_hosts: '192.0.2.10:27017,192.0.2.11:27017', + replica_set: 'rs0' + }); + }); + + it('should validate mongodb seed hosts format', () => { + expect(validateMongoSeedHosts('192.0.2.10:27017,27018,27019')).toBeTruthy(); + expect(validateMongoSeedHosts('192.0.2.10')).toBeFalsy(); + expect(validateMongoSeedHosts('192.0.2.10:70000')).toBeFalsy(); + }); + + it('should omit empty mongodb seed hosts from request params', () => { + expect( + normalizeMongoRequestParams([ + { name: 'auth_source', value: 'admin' }, + { name: 'seed_hosts', value: '' }, + { name: 'direct_connection', value: 'false' }, + { name: 'tls', value: 'true' } + ]) + ).toEqual([{ name: 'auth_source', value: 'admin' }]); + expect( + normalizeMongoRequestParams([{ name: 'seed_hosts', value: 'h:27017' }]) + ).toEqual([{ name: 'seed_hosts', value: 'h:27017' }]); + }); }); From aa6d07dd940e71f12a3891f1093f57701d4a98b0 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 28 Jul 2026 15:22:39 +0800 Subject: [PATCH 3/6] fix: remove MongoDB legacy advanced param strip from form --- .../components/Form/FormItem/index.tsx | 11 ++------- .../base/src/page/DataSource/tool/index.ts | 23 +++---------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx b/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx index f95403d5fe..351fe7c80c 100644 --- a/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx +++ b/packages/base/src/page/DataSource/components/Form/FormItem/index.tsx @@ -26,7 +26,6 @@ import { DataSourceFormContext } from '../../../context'; import { DEFAULT_MONGO_TOPOLOGY, DEFAULT_REDIS_CONNECTION_MODE, - isMongoLegacyRemovedParam, isRedisDbType, MONGODB_AUTH_SOURCE_PARAM, MONGODB_MAIN_PARAMS, @@ -99,11 +98,7 @@ const DatabaseFormItem: React.FC<{ ...MONGODB_MAIN_PARAMS, MONGODB_REPLICA_SET_PARAM, MONGODB_SEED_HOSTS_PARAM, - MONGODB_TOPOLOGY_PARAM, - 'auth_mechanism', - 'tls', - 'tls_skip_verify', - 'direct_connection' + MONGODB_TOPOLOGY_PARAM ]), [] ); @@ -125,9 +120,7 @@ const DatabaseFormItem: React.FC<{ return mongodbAsyncParams; } return mongodbAsyncParams.filter( - (item) => - !mongoHandledKeys.has(item.key ?? '') && - !isMongoLegacyRemovedParam(item.key) + (item) => !mongoHandledKeys.has(item.key ?? '') ); }, [isMongoDB, mongodbAsyncParams, mongoHandledKeys]); diff --git a/packages/base/src/page/DataSource/tool/index.ts b/packages/base/src/page/DataSource/tool/index.ts index adc2b60280..9f86a74573 100644 --- a/packages/base/src/page/DataSource/tool/index.ts +++ b/packages/base/src/page/DataSource/tool/index.ts @@ -218,20 +218,6 @@ export const MONGODB_REPLICA_PARAMS = [ MONGODB_SEED_HOSTS_PARAM ] as const; -/** - * 后端已删除、UI 不再展示的遗留 additional_params。 - * 编辑存量数据源时仍可能出现在表单初始值中,提交与自动表单项均剥离。 - */ -const MONGODB_LEGACY_REMOVED_PARAMS = new Set([ - 'auth_mechanism', - 'tls', - 'tls_skip_verify', - 'direct_connection' -]); - -export const isMongoLegacyRemovedParam = (key?: string) => - !!key && MONGODB_LEGACY_REMOVED_PARAMS.has(key); - const splitMongoSeedHosts = (value?: string) => (value ?? '') .split(/[\n,]/) @@ -308,10 +294,7 @@ export const normalizeMongoRequestParams = < >( params: T[] ) => { - return params.filter((item) => { - if (isMongoLegacyRemovedParam(item.name)) { - return false; - } - return item.name !== MONGODB_SEED_HOSTS_PARAM || !!item.value; - }); + return params.filter( + (item) => item.name !== MONGODB_SEED_HOSTS_PARAM || !!item.value + ); }; From 1074dececfe9d011682bb36b93be29ec7c4fc466 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 28 Jul 2026 15:22:39 +0800 Subject: [PATCH 4/6] test: drop MongoDB legacy advanced param normalize coverage --- packages/base/src/page/DataSource/tool/index.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/base/src/page/DataSource/tool/index.test.ts b/packages/base/src/page/DataSource/tool/index.test.ts index d640cbc3c2..b793ec74fd 100644 --- a/packages/base/src/page/DataSource/tool/index.test.ts +++ b/packages/base/src/page/DataSource/tool/index.test.ts @@ -69,9 +69,7 @@ describe('datasource tool', () => { expect( normalizeMongoRequestParams([ { name: 'auth_source', value: 'admin' }, - { name: 'seed_hosts', value: '' }, - { name: 'direct_connection', value: 'false' }, - { name: 'tls', value: 'true' } + { name: 'seed_hosts', value: '' } ]) ).toEqual([{ name: 'auth_source', value: 'admin' }]); expect( From 67939b7474ac1923c056317c0ec67b5e22bef1ec Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 28 Jul 2026 15:41:55 +0800 Subject: [PATCH 5/6] test: update SyncDataSource form snapshots for confirm switch wrapper --- .../Form/__snapshots__/index.test.tsx.snap | 180 ++++++++++-------- 1 file changed, 100 insertions(+), 80 deletions(-) diff --git a/packages/base/src/page/SyncDataSource/Form/__snapshots__/index.test.tsx.snap b/packages/base/src/page/SyncDataSource/Form/__snapshots__/index.test.tsx.snap index ecbe26c18e..3d2d97b67b 100644 --- a/packages/base/src/page/SyncDataSource/Form/__snapshots__/index.test.tsx.snap +++ b/packages/base/src/page/SyncDataSource/Form/__snapshots__/index.test.tsx.snap @@ -424,26 +424,30 @@ exports[`page/SyncDataSource/SyncTaskForm render for loading form 1`] = `
- + class="ant-switch-inner" + > + + + + +
@@ -1288,26 +1292,30 @@ exports[`page/SyncDataSource/SyncTaskForm render name rule 1`] = `
- + class="ant-switch-inner" + > + + + + +
@@ -2152,26 +2160,30 @@ exports[`page/SyncDataSource/SyncTaskForm render name rule 2`] = `
- + class="ant-switch-inner" + > + + + + +
@@ -3003,26 +3015,30 @@ exports[`page/SyncDataSource/SyncTaskForm render syncInterval for form item 1`]
- + class="ant-switch-inner" + > + + + + +
@@ -3865,26 +3881,30 @@ exports[`page/SyncDataSource/SyncTaskForm render syncInterval for form item 2`]
- + class="ant-switch-inner" + > + + + + +
From 2efc474ee45359eb21cc1d687c71dcf31a450b63 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 28 Jul 2026 17:25:41 +0800 Subject: [PATCH 6/6] test: align DataSource snapshots and Mongo submit assertions for CI Update ConfirmSwitch wrapper snapshots and Mongo/UpdateDataSource expectations so test(4) matches current form behavior. Co-authored-by: Cursor --- .../__snapshots__/index.test.tsx.snap | 88 +++--- .../components/AddDataSource/index.test.tsx | 15 +- .../__snapshots__/index.test.tsx.snap | 108 ++++---- .../Form/__snapshots__/index.ce.test.tsx.snap | 36 +-- .../Form/__snapshots__/index.test.tsx.snap | 252 ++++++++++-------- .../__snapshots__/index.test.tsx.snap | 108 ++++---- .../UpdateDataSource/index.test.tsx | 5 +- .../AddPage/__snapshots__/index.test.tsx.snap | 108 ++++---- .../__snapshots__/index.test.tsx.snap | 180 +++++++------ 9 files changed, 510 insertions(+), 390 deletions(-) diff --git a/packages/base/src/page/DataSource/components/AddDataSource/__snapshots__/index.test.tsx.snap b/packages/base/src/page/DataSource/components/AddDataSource/__snapshots__/index.test.tsx.snap index 7c86bca849..9568e78989 100644 --- a/packages/base/src/page/DataSource/components/AddDataSource/__snapshots__/index.test.tsx.snap +++ b/packages/base/src/page/DataSource/components/AddDataSource/__snapshots__/index.test.tsx.snap @@ -803,26 +803,30 @@ exports[`page/DataSource/AddDataSource render add database snap 1`] = `
- + class="ant-switch-inner" + > + + + + +
@@ -1170,6 +1174,22 @@ exports[`page/DataSource/AddDataSource render add database snap 1`] = ` exports[`page/DataSource/AddDataSource render conenctable modal when current service can not connect 1`] = ` +