[Improve] support resource group (#2742)

* [Improve] support resource group

* [Improve] change group icon

---------

Co-authored-by: zhoulii <zhouli16@hikvision.com.cn>
diff --git a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/AppBuildPipeServiceImpl.java b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/AppBuildPipeServiceImpl.java
index af3a23c..ac4917b 100644
--- a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/AppBuildPipeServiceImpl.java
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/AppBuildPipeServiceImpl.java
@@ -42,6 +42,7 @@
 import org.apache.streampark.console.core.enums.NoticeType;
 import org.apache.streampark.console.core.enums.OptionState;
 import org.apache.streampark.console.core.enums.ReleaseState;
+import org.apache.streampark.console.core.enums.ResourceType;
 import org.apache.streampark.console.core.mapper.ApplicationBuildPipelineMapper;
 import org.apache.streampark.console.core.service.AppBuildPipeService;
 import org.apache.streampark.console.core.service.ApplicationBackUpService;
@@ -82,6 +83,7 @@
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fasterxml.jackson.core.JsonProcessingException;
 import com.github.benmanes.caffeine.cache.Cache;
 import com.github.benmanes.caffeine.cache.Caffeine;
 import lombok.extern.slf4j.Slf4j;
@@ -595,32 +597,58 @@
           .forEach(
               resourceId -> {
                 Resource resource = resourceService.getById(resourceId);
-                Dependency dependency = Dependency.toDependency(resource.getResource());
-                dependency
-                    .getPom()
-                    .forEach(
-                        pom -> {
-                          mvnArtifacts.add(
-                              new Artifact(
-                                  pom.getGroupId(),
-                                  pom.getArtifactId(),
-                                  pom.getVersion(),
-                                  pom.getClassifier()));
-                        });
-                dependency
-                    .getJar()
-                    .forEach(
-                        jar -> {
-                          jarLibs.add(
-                              String.format(
-                                  "%s/%d/%s",
-                                  Workspace.local().APP_UPLOADS(), application.getTeamId(), jar));
-                        });
+
+                if (resource.getResourceType() != ResourceType.GROUP) {
+                  mergeDependency(application, mvnArtifacts, jarLibs, resource);
+                } else {
+                  try {
+                    String[] groupElements =
+                        JacksonUtils.read(resource.getResource(), String[].class);
+                    Arrays.stream(groupElements)
+                        .forEach(
+                            resourceIdInGroup -> {
+                              mergeDependency(
+                                  application,
+                                  mvnArtifacts,
+                                  jarLibs,
+                                  resourceService.getById(resourceIdInGroup));
+                            });
+                  } catch (JsonProcessingException e) {
+                    throw new ApiAlertException("Parse resource group failed.", e);
+                  }
+                }
               });
       return dependencyInfo.merge(mvnArtifacts, jarLibs);
     } catch (Exception e) {
-      log.warn("Merge team dependency failed.");
+      log.warn("Merge team dependency failed.", e);
       return dependencyInfo;
     }
   }
+
+  private static void mergeDependency(
+      Application application,
+      List<Artifact> mvnArtifacts,
+      List<String> jarLibs,
+      Resource resource) {
+    Dependency dependency = Dependency.toDependency(resource.getResource());
+    dependency
+        .getPom()
+        .forEach(
+            pom -> {
+              mvnArtifacts.add(
+                  new Artifact(
+                      pom.getGroupId(),
+                      pom.getArtifactId(),
+                      pom.getVersion(),
+                      pom.getClassifier()));
+            });
+    dependency
+        .getJar()
+        .forEach(
+            jar -> {
+              jarLibs.add(
+                  String.format(
+                      "%s/%d/%s", Workspace.local().APP_UPLOADS(), application.getTeamId(), jar));
+            });
+  }
 }
diff --git a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ResourceServiceImpl.java b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ResourceServiceImpl.java
index ad39530..f702ad0 100644
--- a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ResourceServiceImpl.java
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ResourceServiceImpl.java
@@ -91,7 +91,8 @@
     ApiAlertException.throwIfNull(resourceStr, "Please add pom or jar resource.");
 
     if (resource.getResourceType() == ResourceType.GROUP) {
-      // TODO: will support later
+      ApiAlertException.throwIfNull(
+          resource.getResourceName(), "The name of resource group is required.");
     } else {
       Dependency dependency = Dependency.toDependency(resourceStr);
       List<String> jars = dependency.getJar();
diff --git a/streampark-console/streampark-console-webapp/src/assets/icons/group.svg b/streampark-console/streampark-console-webapp/src/assets/icons/group.svg
new file mode 100644
index 0000000..e7a937d
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/assets/icons/group.svg
@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1683865708492" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16803" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M128 64h256a64 64 0 0 1 64 64v256a64 64 0 0 1-64 64H128a64 64 0 0 1-64-64V128a64 64 0 0 1 64-64z m512 0h256a64 64 0 0 1 64 64v256a64 64 0 0 1-64 64h-256a64 64 0 0 1-64-64V128a64 64 0 0 1 64-64zM128 576h256a64 64 0 0 1 64 64v256a64 64 0 0 1-64 64H128a64 64 0 0 1-64-64v-256a64 64 0 0 1 64-64z m512 0h256a64 64 0 0 1 64 64v256a64 64 0 0 1-64 64h-256a64 64 0 0 1-64-64v-256a64 64 0 0 1 64-64zM128 128v256h256V128H128z m512 0v256h256V128h-256z m-512 512v256h256v-256H128z m512 0v256h256v-256h-256z" fill="#1890ff" p-id="16804"></path></svg>
\ No newline at end of file
diff --git a/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/resource.ts b/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/resource.ts
index 84cc1d1..19ffd16 100644
--- a/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/resource.ts
+++ b/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/resource.ts
@@ -23,7 +23,12 @@
   uploadResource: 'Upload Resource',
   resourceType: 'Resource Type',
   engineType: 'Engine Type',
+  resourceGroup: 'Resource Group',
+  groupName: 'Group Name',
   engineTypePlaceholder: 'Please select compute engine type',
+  resourceGroupPlaceholder: 'Please choose resource',
+  groupNamePlaceholder: 'Please input the group name',
+  groupNameIsRequiredMessage: 'Group Name is required',
   multiPomTip: 'Do not add multiple dependencies at one time',
   addResourceTip: 'Please add a resource',
   add: 'Add',
diff --git a/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/resource.ts b/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/resource.ts
index 7d9c9ba..023d0f3 100644
--- a/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/resource.ts
+++ b/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/resource.ts
@@ -23,6 +23,11 @@
   uploadResource: '上传资源',
   resourceType: '资源类型',
   engineType: '计算引擎类型',
+  resourceGroup: '资源组',
+  groupName: '资源组名称',
+  resourceGroupPlaceholder: '请选择组资源',
+  groupNamePlaceholder: '请输入资源组名称',
+  groupNameIsRequiredMessage: '资源组名称必填',
   engineTypePlaceholder: '请选择计算引擎类型',
   multiPomTip: '不支持同时添加多个依赖',
   addResourceTip: '请添加资源',
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/resource/View.vue b/streampark-console/streampark-console-webapp/src/views/flink/resource/View.vue
index df46ce3..bc5fb08 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/resource/View.vue
+++ b/streampark-console/streampark-console-webapp/src/views/flink/resource/View.vue
@@ -54,6 +54,13 @@
           >
             UDXF
           </Tag>
+          <Tag
+            class="bold-tag"
+            color="#fcaa80"
+            v-if="record.resourceType == ResourceTypeEnum.GROUP"
+          >
+            GROUP
+          </Tag>
         </template>
         <template v-if="column.dataIndex === 'engineType'">
           <Tag
@@ -95,7 +102,7 @@
         </template>
       </template>
     </BasicTable>
-    <ResourceDrawer @register="registerDrawer" @success="handleSuccess" />
+    <ResourceDrawer :teamResource="teamResource" @register="registerDrawer" @success="handleSuccess" />
   </div>
 </template>
 <script lang="ts">
@@ -105,7 +112,7 @@
 </script>
 
 <script lang="ts" setup>
-  import {defineComponent, ref} from 'vue';
+import {defineComponent, onMounted, ref} from 'vue';
   import { BasicTable, useTable, TableAction, SorterResult } from '/@/components/Table';
   import ResourceDrawer from './components/ResourceDrawer.vue';
   import { useDrawer } from '/@/components/Drawer';
@@ -114,10 +121,15 @@
   import { useI18n } from '/@/hooks/web/useI18n';
   import Icon from '/@/components/Icon';
   import { useRouter } from 'vue-router';
-  import { fetchResourceDelete, fetchResourceList } from "/@/api/flink/resource";
+  import {
+    fetchResourceDelete,
+    fetchResourceList,
+    fetchTeamResource
+  } from "/@/api/flink/resource";
   import { EngineTypeEnum, ResourceTypeEnum } from "/@/views/flink/resource/resource.data";
   import { Tag } from 'ant-design-vue';
 
+  const teamResource = ref<Array<any>>([]);
   const router = useRouter();
   const [registerDrawer, { openDrawer }] = useDrawer();
   const [registerInfo, { openDrawer: openInfoDraw }] = useDrawer();
@@ -180,6 +192,7 @@
     if (data.status === 'success') {
       createMessage.success(t('flink.resource.deleteResource') + t('flink.resource.success'));
       reload();
+      updateTeamResource();
     } else {
       createMessage.error(t('flink.resource.deleteResource') + t('flink.resource.fail'));
     }
@@ -190,6 +203,18 @@
       `${isUpdate ? t('common.edit') : t('flink.resource.add')}${t('flink.resource.success')}`,
     );
     reload();
+    updateTeamResource();
   }
 
+  function updateTeamResource() {
+    /* Get team dependencies */
+    fetchTeamResource({}).then((res) => {
+      teamResource.value = res;
+    });
+  }
+
+  onMounted(async () => {
+    updateTeamResource();
+  });
+
 </script>
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/resource/components/Resource.vue b/streampark-console/streampark-console-webapp/src/views/flink/resource/components/Resource.vue
index 540b0e5..a8a918b 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/resource/components/Resource.vue
+++ b/streampark-console/streampark-console-webapp/src/views/flink/resource/components/Resource.vue
@@ -32,6 +32,7 @@
   import { useMessage } from '/@/hooks/web/useMessage';
   import { fetchUpload } from '/@/api/flink/app/app';
   import UploadJobJar from '/@/views/flink/app/components/UploadJobJar.vue';
+  import {onMounted, unref} from "vue";
 
   interface DependencyType {
     artifactId: string;
@@ -201,6 +202,10 @@
     emit('update:value', data);
   });
 
+  onMounted(async () => {
+    setDefaultValue(JSON.parse(props?.formModel?.dependency || '{}'));
+  });
+
   defineExpose({
     setDefaultValue,
     dependency,
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/resource/components/ResourceDrawer.vue b/streampark-console/streampark-console-webapp/src/views/flink/resource/components/ResourceDrawer.vue
index 75e16ac..8f2d333 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/resource/components/ResourceDrawer.vue
+++ b/streampark-console/streampark-console-webapp/src/views/flink/resource/components/ResourceDrawer.vue
@@ -52,11 +52,21 @@
   import Resource from '/@/views/flink/resource/components/Resource.vue';
   import { fetchAddResource, fetchUpdateResource } from "/@/api/flink/resource";
   import { EngineTypeEnum } from "/@/views/flink/resource/resource.data";
-  import { renderResourceType } from "/@/views/flink/resource/useResourceRender";
+  import {
+    renderResourceType,
+    renderStreamParkResourceGroup
+  } from "/@/views/flink/resource/useResourceRender";
   import { useMessage } from "/@/hooks/web/useMessage";
 
   const emit = defineEmits(['success', 'register']);
 
+  const props = defineProps({
+    teamResource: {
+      type: Object as Array<any>,
+      required: true,
+    },
+  });
+
   const { t } = useI18n();
   const { Swal } = useMessage();
 
@@ -89,10 +99,27 @@
         rules: [{ required: true, message: t('flink.resource.form.engineTypeIsRequiredMessage') }],
       },
       {
+        field: 'resourceName',
+        label: t('flink.resource.groupName'),
+        component: 'Input',
+        componentProps: { placeholder: t('flink.resource.groupNamePlaceholder') },
+        ifShow: ({ values }) => values?.resourceType == 'GROUP',
+        rules: [{ required: true, message: t('flink.resource.groupNameIsRequiredMessage') }],
+      },
+      {
+        field: 'resourceGroup',
+        label: t('flink.resource.resourceGroup'),
+        component: 'Select',
+        render: ({ model }) =>
+          renderStreamParkResourceGroup( { model, resources: unref(props.teamResource) }, ),
+        ifShow: ({ values }) => values?.resourceType == 'GROUP',
+      },
+      {
         field: 'dependency',
         label: t('flink.resource.addResource'),
         component: 'Input',
         slot: 'resource',
+        ifShow: ({ values }) => values?.resourceType !== 'GROUP',
       },
       {
         field: 'mainClass',
@@ -123,13 +150,21 @@
 
   const [registerDrawer, { setDrawerProps, changeLoading, closeDrawer }] = useDrawerInner(
     async (data: Recordable) => {
+      unref(resourceRef)?.setDefaultValue({});
       resetFields();
       setDrawerProps({ confirmLoading: false });
       isUpdate.value = !!data?.isUpdate;
       if (unref(isUpdate)) {
         resourceId.value = data.record.id;
         setFieldsValue(data.record);
-        unref(resourceRef)?.setDefaultValue(JSON.parse(data.record.resource || '{}'));
+
+        if (data.record?.resourceType == 'GROUP') {
+          setFieldsValue({ resourceGroup: JSON.parse(data.record.resource || '[]')} );
+        } else {
+          setFieldsValue({ dependency: data.record.resource});
+          unref(resourceRef)?.setDefaultValue(JSON.parse(data.record.resource || '{}'));
+        }
+
       }
     },
   );
@@ -140,43 +175,51 @@
 
   // form submit
   async function handleSubmit() {
-    const resource: { pom?: string; jar?: string } = {};
-    unref(resourceRef).handleApplyPom();
-    const dependencyRecords = unref(resourceRef)?.dependencyRecords;
-    const uploadJars = unref(resourceRef)?.uploadJars;
-
-    if (unref(dependencyRecords) && unref(dependencyRecords).length > 0) {
-      if (unref(dependencyRecords).length > 1) {
-        Swal.fire('Failed', t('flink.resource.multiPomTip'), 'error');
-        return;
-      }
-      Object.assign(resource, {
-        pom: unref(dependencyRecords),
-      });
-    }
-
-    if (uploadJars && unref(uploadJars).length > 0) {
-      Object.assign(resource, {
-        jar: unref(uploadJars),
-      });
-    }
-
-    if (resource.pom === undefined && resource.jar === undefined) {
-      Swal.fire('Failed', t('flink.resource.addResourceTip'), 'error');
-      return;
-    }
-
-    if (resource.pom?.length > 0 && resource.jar?.length > 0) {
-      Swal.fire('Failed', t('flink.resource.multiPomTip'), 'error');
-      return;
-    }
-
     try {
       const values = await validate();
+      let resourceJson: string = '';
+
+      if (values.resourceType == 'GROUP') {
+        resourceJson = JSON.stringify(values.resourceGroup);
+      } else {
+        const resource: { pom?: string; jar?: string } = {};
+        unref(resourceRef).handleApplyPom();
+        const dependencyRecords = unref(resourceRef)?.dependencyRecords;
+        const uploadJars = unref(resourceRef)?.uploadJars;
+
+        if (unref(dependencyRecords) && unref(dependencyRecords).length > 0) {
+          if (unref(dependencyRecords).length > 1) {
+            Swal.fire('Failed', t('flink.resource.multiPomTip'), 'error');
+            return;
+          }
+          Object.assign(resource, {
+            pom: unref(dependencyRecords),
+          });
+        }
+
+        if (uploadJars && unref(uploadJars).length > 0) {
+          Object.assign(resource, {
+            jar: unref(uploadJars),
+          });
+        }
+
+        if (resource.pom === undefined && resource.jar === undefined) {
+          Swal.fire('Failed', t('flink.resource.addResourceTip'), 'error');
+          return;
+        }
+
+        if (resource.pom?.length > 0 && resource.jar?.length > 0) {
+          Swal.fire('Failed', t('flink.resource.multiPomTip'), 'error');
+          return;
+        }
+
+        resourceJson = JSON.stringify(resource);
+      }
+
       setDrawerProps({ confirmLoading: true });
       await (isUpdate.value
-        ? fetchUpdateResource({ id: resourceId.value, resource: JSON.stringify(resource), ...values })
-        : fetchAddResource({ resource: JSON.stringify(resource), ...values }));
+        ? fetchUpdateResource({ id: resourceId.value, resource: resourceJson, ...values })
+        : fetchAddResource({ resource: resourceJson, ...values }));
       unref(resourceRef)?.setDefaultValue({});
       resetFields();
       closeDrawer();
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/resource/resource.data.ts b/streampark-console/streampark-console-webapp/src/views/flink/resource/resource.data.ts
index df0c021..cc375d5 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/resource/resource.data.ts
+++ b/streampark-console/streampark-console-webapp/src/views/flink/resource/resource.data.ts
@@ -23,6 +23,7 @@
   CONNECTOR = 'CONNECTOR',
   UDXF = 'UDXF',
   NORMAL_JAR = 'NORMAL_JAR',
+  GROUP = 'GROUP',
 }
 
 export enum EngineTypeEnum {
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/resource/useResourceRender.tsx b/streampark-console/streampark-console-webapp/src/views/flink/resource/useResourceRender.tsx
index ba48164..86664de 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/resource/useResourceRender.tsx
+++ b/streampark-console/streampark-console-webapp/src/views/flink/resource/useResourceRender.tsx
@@ -14,13 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { Select } from 'ant-design-vue';
+import {Select, Tag} from 'ant-design-vue';
 import { useI18n } from '/@/hooks/web/useI18n';
 import { ResourceTypeEnum } from "/@/views/flink/resource/resource.data";
 import flinkAppSvg from '/@/assets/icons/flink2.svg';
 import connectorSvg from '/@/assets/icons/connector.svg';
 import udxfSvg from '/@/assets/icons/fx.svg';
 import normalJarSvg from '/@/assets/icons/jar.svg';
+import groupSvg from '/@/assets/icons/group.svg';
 
 const { t } = useI18n();
 
@@ -33,6 +34,7 @@
       { label: 'Connector', value: ResourceTypeEnum.CONNECTOR, src: connectorSvg },
       { label: 'UDXF', value: ResourceTypeEnum.UDXF, src: udxfSvg },
       { label: 'Normal Jar', value: ResourceTypeEnum.NORMAL_JAR, src: normalJarSvg },
+      { label: 'Group', value: ResourceTypeEnum.GROUP, src: groupSvg },
     ];
     return options
       .map(( {label,value, src} ) => {
@@ -70,3 +72,46 @@
     </div>
   );
 };
+
+export const renderStreamParkResourceGroup = ({ model, resources },) => {
+
+  const renderOptions = () => {
+    console.log('resources', resources);
+    return (resources || [])
+      .filter((item) => item.resourceType !== ResourceTypeEnum.FLINK_APP
+        && item.resourceType !== ResourceTypeEnum.GROUP)
+      .map((resource) => {
+        return (
+          <Select.Option
+            key={resource.id}
+            label={ resource.resourceType + '-' + resource.resourceName}>
+            <div>
+              <Tag color="green" style=";margin-left: 5px;" size="small">
+                {resource.resourceType}
+              </Tag>
+              <span style="color: darkgrey">
+              {resource.resourceName}
+            </span>
+            </div>
+          </Select.Option>
+        );
+      });
+  };
+
+  return (
+    <div>
+      <Select
+        show-search
+        allow-clear
+        optionFilterProp="label"
+        mode="multiple"
+        max-tag-count={3}
+        onChange={(value) => (model.resourceGroup = value)}
+        value={model.resourceGroup}
+        placeholder={t('flink.resource.resourceGroupPlaceholder')}
+      >
+        {renderOptions()}
+      </Select>
+    </div>
+  );
+};