This is an automated email from the ASF dual-hosted git repository.

benjobs pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/incubator-streampark.git


The following commit(s) were added to refs/heads/dev by this push:
     new 1c6d8fd2b [Improve]: add spark application create page (#3992)
1c6d8fd2b is described below

commit 1c6d8fd2b534b681702e6da429a0a267a407aa43
Author: Kriszu <[email protected]>
AuthorDate: Sat Aug 24 20:10:58 2024 +0800

    [Improve]: add spark application create page (#3992)
---
 .../streampark-console-webapp/src/api/spark/app.ts | 182 +++++++++
 .../src/api/spark/app.type.ts                      | 181 +++++++++
 .../src/api/spark/conf.ts                          |  33 ++
 .../streampark-console-webapp/src/api/spark/sql.ts |  30 ++
 .../components/SimpleMenu/src/SimpleSubMenu.vue    |  10 +-
 .../src/enums/sparkEnum.ts                         |  33 ++
 .../flink/app/hooks/useCreateAndEditSchema.ts      |   1 +
 .../views/spark/app/components/AppDashboard.vue    | 101 +++++
 .../views/spark/app/components/AppStartModal.vue   | 244 ++++++++++++
 .../src/views/spark/app/components/Mergely.vue     | 202 ++++++++++
 .../src/views/spark/app/components/ProgramArgs.vue | 128 +++++++
 .../src/views/spark/app/components/SparkSql.vue    | 301 +++++++++++++++
 .../views/spark/app/components/StatisticCard.vue   |  54 +++
 .../views/spark/app/components/VariableReview.vue  |  65 ++++
 .../src/views/spark/app/create.vue                 | 201 ++++++++++
 .../src/views/spark/app/data/index.ts              |  81 ++++
 .../src/views/spark/app/hooks/useAppFormSchema.tsx | 264 +++++++++++++
 .../src/views/spark/app/hooks/useFlinkRender.tsx   | 326 ++++++++++++++++
 .../src/views/spark/app/hooks/useSparkAction.tsx   | 192 ++++++++++
 .../src/views/spark/app/hooks/useSparkColumns.ts   | 101 +++++
 .../views/spark/app/hooks/useSparkTableAction.ts   | 234 ++++++++++++
 .../src/views/spark/app/index.vue                  | 317 ++++++++++++++++
 .../src/views/spark/app/sqlFormatter.js            | 408 +++++++++++++++++++++
 .../src/views/spark/app/styles/View.less           | 176 +++++++++
 24 files changed, 3860 insertions(+), 5 deletions(-)

diff --git a/streampark-console/streampark-console-webapp/src/api/spark/app.ts 
b/streampark-console/streampark-console-webapp/src/api/spark/app.ts
new file mode 100644
index 000000000..76b57a746
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/api/spark/app.ts
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { AppListResponse, SparkApplication, DashboardResponse } from 
'./app.type';
+import type { Result } from '/#/axios';
+import { ContentTypeEnum } from '/@/enums/httpEnum';
+import type { AppExistsStateEnum } from '/@/enums/sparkEnum';
+import { defHttp } from '/@/utils/http/axios';
+
+const apiPrefix = `/spark/app`;
+
+/**
+ * Get application information by id
+ * @param params get parameters
+ */
+export function fetchGetSparkApp(data: { id: string }) {
+  return defHttp.post<SparkApplication>({ url: `${apiPrefix}/get`, data });
+}
+/**
+ * create spark application information
+ * @param params get parameters
+ */
+export function fetchCreateSparkApp(data: SparkApplication) {
+  return defHttp.post<boolean>({ url: `${apiPrefix}/create`, data });
+}
+/**
+ * copy spark application information
+ * @param params get parameters
+ */
+export function fetchCopySparkApp(data: SparkApplication) {
+  return defHttp.post({ url: `${apiPrefix}/copy`, data });
+}
+/**
+ * update spark application information
+ * @param params get parameters
+ */
+export function fetchUpdateSparkApp(data: SparkApplication) {
+  return defHttp.post<boolean>({ url: `${apiPrefix}/update`, data });
+}
+
+/**
+ * Dashboard data
+ * @returns Promise<DashboardResponse>
+ */
+export function fetchSparkDashboard() {
+  return defHttp.post<DashboardResponse>({
+    url: `${apiPrefix}/dashboard`,
+  });
+}
+
+/**
+ * Get app list data
+ */
+export function fetchSparkAppRecord(data: Recordable) {
+  return defHttp.post<AppListResponse>({ url: `${apiPrefix}/list`, data });
+}
+
+/**
+ * mapping
+ * @param params {id:string,appId:string,jobId:string}
+ */
+export function fetchSparkMapping(data: SparkApplication) {
+  return defHttp.post<boolean>({ url: `${apiPrefix}/mapping`, data });
+}
+
+export function fetchSparkAppStart(data: SparkApplication) {
+  return defHttp.post<Result<boolean>>(
+    { url: `${apiPrefix}/start`, data },
+    { isTransformResponse: false },
+  );
+}
+
+export function fetchCheckSparkAppStart(data: SparkApplication) {
+  return defHttp.post<AppExistsStateEnum>({ url: `${apiPrefix}/check/start`, 
data });
+}
+
+/**
+ * Cancel
+ * @param {CancelParam} data
+ */
+export function fetchSparkAppCancel(data: SparkApplication) {
+  return defHttp.post({ url: `${apiPrefix}/cancel`, data });
+}
+
+/**
+ * clean
+ * @param {CancelParam} data
+ */
+export function fetchSparkAppClean(data: SparkApplication) {
+  return defHttp.post({ url: `${apiPrefix}/clean`, data });
+}
+/**
+ * forcedStop
+ */
+export function fetchSparkAppForcedStop(data: SparkApplication) {
+  return defHttp.post({ url: `${apiPrefix}/forcedStop`, data });
+}
+
+/**
+ * get yarn address
+ */
+export function fetchSparkYarn() {
+  return defHttp.post<string>({ url: `${apiPrefix}/yarn` });
+}
+
+/**
+ * check spark name
+ */
+export function fetchCheckSparkName(data: { id?: string; jobName: string }) {
+  return defHttp.post<AppExistsStateEnum>({ url: `${apiPrefix}/check/name`, 
data });
+}
+
+/**
+ * read configuration file
+ */
+export function fetchSparkAppConf(params?: { config: any }) {
+  return defHttp.post<string>({
+    url: `${apiPrefix}/read_conf`,
+    params,
+  });
+}
+
+/**
+ * main
+ */
+export function fetchSparkMain(data: SparkApplication) {
+  return defHttp.post<string>({
+    url: `${apiPrefix}/main`,
+    data,
+  });
+}
+
+export function fetchSparkBackUps(data: SparkApplication) {
+  return defHttp.post({ url: `${apiPrefix}/backups`, data });
+}
+
+export function fetchSparkOptionLog(data: SparkApplication) {
+  return defHttp.post({ url: `${apiPrefix}/opt_log`, data });
+}
+
+export function fetchSparkDeleteOptLog(id: string) {
+  return defHttp.post({ url: `${apiPrefix}/delete/opt_log`, data: { id } });
+}
+
+/**
+ * remove the app
+ */
+export function fetchSparkAppRemove(id: string) {
+  return defHttp.post({ url: `${apiPrefix}/delete`, data: { id } });
+}
+
+export function fetchSparkRemoveBackup(id: string) {
+  return defHttp.post({ url: `${apiPrefix}/delete/bak`, data: { id } });
+}
+
+/**
+ * upload
+ * @param params
+ */
+export function fetchSparkUpload(params: any) {
+  return defHttp.post<string>({
+    url: `${apiPrefix}/upload`,
+    params,
+    headers: {
+      'Content-Type': ContentTypeEnum.FORM_DATA,
+    },
+    timeout: 1000 * 60 * 10, // Uploading files timed out for 10 minutes
+  });
+}
diff --git 
a/streampark-console/streampark-console-webapp/src/api/spark/app.type.ts 
b/streampark-console/streampark-console-webapp/src/api/spark/app.type.ts
new file mode 100644
index 000000000..1bdd20e23
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/api/spark/app.type.ts
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+// dashboard
+export interface DashboardResponse {
+  totalTM: number;
+  task: Task;
+  availableSlot: number;
+  totalSlot: number;
+  runningJob: number;
+  tmMemory: number;
+  jmMemory: number;
+}
+
+interface Task {
+  total: number;
+  created: number;
+  scheduled: number;
+  deploying: number;
+  running: number;
+  finished: number;
+  canceling: number;
+  canceled: number;
+  failed: number;
+  reconciling: number;
+}
+// The list of data
+export interface AppListResponse {
+  total: string;
+  records: SparkApplication[];
+}
+export interface SparkApplication {
+  id?: string;
+  teamId?: string;
+  jobType?: number;
+  appType?: number;
+  versionId?: number;
+  appName?: string;
+  executionMode?: number;
+  resourceFrom?: number;
+  module?: any;
+  mainClass?: string;
+  jar?: string;
+  jarCheckSum?: string;
+  appProperties?: string;
+  appArgs?: string;
+  appId?: string;
+  yarnQueue?: any;
+
+  projectId?: any;
+  tags?: any;
+  userId?: string;
+  jobName?: string;
+  jobId?: string;
+  clusterId?: string;
+  flinkImage?: string;
+  k8sNamespace?: string;
+  state?: number;
+  release?: number;
+  build?: boolean;
+  restartSize?: number;
+  restartCount?: number;
+  optionState?: number;
+  alertId?: any;
+  args?: string;
+  options?: string;
+  hotParams?: string;
+  resolveOrder?: number;
+  dynamicProperties?: string;
+  tracking?: number;
+
+  startTime?: string;
+  endTime?: string;
+  duration?: string;
+  cpMaxFailureInterval?: any;
+  cpFailureRateInterval?: any;
+  cpFailureAction?: any;
+  totalTM?: any;
+  totalSlot?: any;
+  availableSlot?: any;
+  jmMemory?: number;
+  tmMemory?: number;
+  totalTask?: number;
+  flinkClusterId?: any;
+  description?: string;
+  createTime?: string;
+  optionTime?: string;
+  modifyTime?: string;
+  k8sRestExposedType?: any;
+  k8sPodTemplate?: any;
+  k8sJmPodTemplate?: any;
+  k8sTmPodTemplate?: any;
+  ingressTemplate?: any;
+  defaultModeIngress?: any;
+  k8sHadoopIntegration?: boolean;
+  overview?: any;
+  teamResource?: any;
+  dependency?: any;
+  sqlId?: any;
+  flinkSql?: any;
+  stateArray?: any;
+  jobTypeArray?: any;
+  backUp?: boolean;
+  restart?: boolean;
+  userName?: string;
+  nickName?: string;
+  config?: any;
+  configId?: any;
+  flinkVersion?: string;
+  confPath?: any;
+  format?: any;
+  savepointPath?: any;
+  restoreOrTriggerSavepoint?: boolean;
+  drain?: boolean;
+  nativeFormat?: boolean;
+  allowNonRestored?: boolean;
+  socketId?: any;
+  projectName?: any;
+  createTimeFrom?: any;
+  createTimeTo?: any;
+  backUpDescription?: any;
+  teamIdList?: any;
+  teamName?: string;
+  flinkRestUrl?: any;
+  buildStatus?: number;
+  appControl?: AppControl;
+  fsOperator?: any;
+  workspace?: any;
+  k8sPodTemplates?: {
+    empty?: boolean;
+  };
+  streamParkJob?: boolean;
+  hadoopUser?: string;
+}
+
+interface AppControl {
+  allowStart: boolean;
+  allowStop: boolean;
+  allowBuild: boolean;
+}
+
+// create Params
+export interface CreateParams {
+  jobType: number;
+  executionMode: number;
+  versionId: string;
+  flinkSql: string;
+  appType: number;
+  config?: any;
+  format?: any;
+  jobName: string;
+  tags: string;
+  args?: any;
+  dependency: string;
+  options: string;
+  cpMaxFailureInterval: number;
+  cpFailureRateInterval: number;
+  cpFailureAction: number;
+  dynamicProperties: string;
+  resolveOrder: number;
+  restartSize: number;
+  alertId: string;
+  description: string;
+  k8sNamespace?: any;
+  clusterId: string;
+  flinkClusterId: string;
+  flinkImage?: any;
+}
diff --git a/streampark-console/streampark-console-webapp/src/api/spark/conf.ts 
b/streampark-console/streampark-console-webapp/src/api/spark/conf.ts
new file mode 100644
index 000000000..e4e0ccb59
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/api/spark/conf.ts
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { defHttp } from '/@/utils/http/axios';
+
+const apiPrefix = '/flink/conf';
+
+export function fetchGetSparkConf(data: { id: string }) {
+  return defHttp.post({ url: `${apiPrefix}/get`, data });
+}
+export function handleSparkConfTemplate() {
+  return defHttp.post<string>({
+    url: `${apiPrefix}/template`,
+  });
+}
+export function fetchSysHadoopConf() {
+  return defHttp.post({
+    url: `${apiPrefix}/sysHadoopConf`,
+  });
+}
diff --git a/streampark-console/streampark-console-webapp/src/api/spark/sql.ts 
b/streampark-console/streampark-console-webapp/src/api/spark/sql.ts
new file mode 100644
index 000000000..8d3ada344
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/api/spark/sql.ts
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { defHttp } from '/@/utils/http/axios';
+const apiPrefix = '/flink/conf';
+
+export function fetchSparkSqlVerify(data: Recordable) {
+  return defHttp.post({ url: `${apiPrefix}/verify`, data }, { 
isTransformResponse: false });
+}
+
+export function fetchSparkSql(data: Recordable) {
+  return defHttp.post({
+    url: `${apiPrefix}/get`,
+    data,
+  });
+}
diff --git 
a/streampark-console/streampark-console-webapp/src/components/SimpleMenu/src/SimpleSubMenu.vue
 
b/streampark-console/streampark-console-webapp/src/components/SimpleMenu/src/SimpleSubMenu.vue
index e0af2a31e..cfdd795a6 100644
--- 
a/streampark-console/streampark-console-webapp/src/components/SimpleMenu/src/SimpleSubMenu.vue
+++ 
b/streampark-console/streampark-console-webapp/src/components/SimpleMenu/src/SimpleSubMenu.vue
@@ -23,11 +23,11 @@
   >
     <template #title>
       <span class="menu-down-svg">
-        <SvgIcon v-if="item.path === '/system'" name="management" size="25" />
-        <SvgIcon v-if="item.path === '/flink'" name="flink3" size="25" />
-        <SvgIcon v-if="item.path === '/spark'" name="spark" size="25"/>
-        <SvgIcon v-if="item.path === '/setting'" name="settings" size="25" />
-        <SvgIcon v-if="item.path === '/resource'" name="resource" size="25" />
+        <SvgIcon v-if="item.path === '/system'" name="management" size="20" />
+        <SvgIcon v-if="item.path === '/flink'" name="flink3" size="20" />
+        <SvgIcon v-if="item.path === '/spark'" name="spark" size="20" />
+        <SvgIcon v-if="item.path === '/setting'" name="settings" size="20" />
+        <SvgIcon v-if="item.path === '/resource'" name="resource" size="20" />
       </span>
       <div v-if="collapsedShowTitle && getIsCollapseParent" class="mt-2 
collapse-title">
         {{ getI18nName }}
diff --git 
a/streampark-console/streampark-console-webapp/src/enums/sparkEnum.ts 
b/streampark-console/streampark-console-webapp/src/enums/sparkEnum.ts
index 96e1d180a..a1d4fbd20 100644
--- a/streampark-console/streampark-console-webapp/src/enums/sparkEnum.ts
+++ b/streampark-console/streampark-console-webapp/src/enums/sparkEnum.ts
@@ -5,3 +5,36 @@ export enum SparkEnvCheckEnum {
   SPARK_DIST_NOT_FOUND = 2,
   SPARK_DIST_REPEATED = 3,
 }
+
+export enum JobTypeEnum {
+  JAR = 1,
+  SQL = 2,
+  PYSPARK = 3,
+}
+
+/* ExecutionMode  */
+export enum ExecModeEnum {
+  /** remote (standalone) */
+  REMOTE = 1,
+  /** yarn per-job (deprecated, please use yarn-application mode) */
+  YARN_CLUSTER = 2,
+  /** yarn session */
+  YARN_CLIENT = 3,
+}
+
+export enum AppExistsStateEnum {
+  /** no exists */
+  NO,
+
+  /** exists in database */
+  IN_DB,
+
+  /** exists in yarn */
+  IN_YARN,
+
+  /** exists in remote kubernetes cluster. */
+  IN_KUBERNETES,
+
+  /** job name invalid because of special utf-8 character */
+  INVALID,
+}
diff --git 
a/streampark-console/streampark-console-webapp/src/views/flink/app/hooks/useCreateAndEditSchema.ts
 
b/streampark-console/streampark-console-webapp/src/views/flink/app/hooks/useCreateAndEditSchema.ts
index d3263d2a1..de0fa14cd 100644
--- 
a/streampark-console/streampark-console-webapp/src/views/flink/app/hooks/useCreateAndEditSchema.ts
+++ 
b/streampark-console/streampark-console-webapp/src/views/flink/app/hooks/useCreateAndEditSchema.ts
@@ -534,6 +534,7 @@ export const useCreateAndEditSchema = (
           } else if (model.appType == AppTypeEnum.STREAMPARK_SPARK) {
             return getAlertSvgIcon('spark', 'StreamPark Spark');
           }
+          return '';
         },
       },
     ];
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/AppDashboard.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/AppDashboard.vue
new file mode 100644
index 000000000..bd97f7cb3
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/AppDashboard.vue
@@ -0,0 +1,101 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<script lang="ts" setup>
+  import { onMounted, reactive, ref } from 'vue';
+  import StatisticCard from './StatisticCard.vue';
+  import { Row, Col } from 'ant-design-vue';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { fetchSparkDashboard } from '/@/api/spark/app';
+  const dashBigScreenMap = reactive<Recordable>({});
+  const dashboardLoading = ref(true);
+  const { t } = useI18n();
+
+  // Get Dashboard Metrics Data
+  async function handleDashboard(showLoading: boolean) {
+    try {
+      dashboardLoading.value = showLoading;
+      const res = await fetchSparkDashboard();
+      if (res) {
+        Object.assign(dashBigScreenMap, {
+          runningJob: {
+            staticstics: { title: t('flink.app.dashboard.runningJobs'), value: 
res.runningJob },
+            footer: [
+              { title: t('flink.app.dashboard.totalTask'), value: 
res?.task?.total || 0 },
+              { title: t('flink.app.dashboard.runningTask'), value: 
res?.task?.running || 0 },
+            ],
+          },
+          availiableTask: {
+            staticstics: {
+              title: t('flink.app.dashboard.availableTaskSlots'),
+              value: res.availableSlot,
+            },
+            footer: [
+              { title: t('flink.app.dashboard.taskSlots'), value: 
res.totalSlot },
+              { title: t('flink.app.dashboard.taskManagers'), value: 
res.totalTM },
+            ],
+          },
+          jobManager: {
+            staticstics: { title: t('flink.app.dashboard.jobManagerMemory'), 
value: res.jmMemory },
+            footer: [
+              {
+                title: t('flink.app.dashboard.totalJobManagerMemory'),
+                value: `${res.jmMemory} MB`,
+              },
+            ],
+          },
+          taskManager: {
+            staticstics: { title: t('flink.app.dashboard.taskManagerMemory'), 
value: res.tmMemory },
+            footer: [
+              {
+                title: t('flink.app.dashboard.totalTaskManagerMemory'),
+                value: `${res.tmMemory} MB`,
+              },
+            ],
+          },
+        });
+      }
+    } catch (error) {
+      console.error(error);
+    } finally {
+      dashboardLoading.value = false;
+    }
+  }
+
+  onMounted(() => {
+    handleDashboard(true);
+  });
+
+  defineExpose({ handleDashboard });
+</script>
+<template>
+  <Row :gutter="24" class="dashboard">
+    <Col
+      class="gutter-row mt-10px"
+      :md="6"
+      :xs="24"
+      v-for="(value, key) in dashBigScreenMap"
+      :key="key"
+    >
+      <StatisticCard
+        :statisticProps="value.staticstics"
+        :footerList="value.footer"
+        :loading="dashboardLoading"
+      />
+    </Col>
+  </Row>
+</template>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/AppStartModal.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/AppStartModal.vue
new file mode 100644
index 000000000..3fd5976ef
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/AppStartModal.vue
@@ -0,0 +1,244 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts" name="StartApplicationModal">
+  import { reactive } from 'vue';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { exceptionPropWidth } from '/@/utils';
+  import { h, ref } from 'vue';
+  import { Select, Input, Tag } from 'ant-design-vue';
+  import { BasicForm, useForm } from '/@/components/Form';
+  import { SvgIcon, Icon } from '/@/components/Icon';
+  import { BasicModal, useModalInner } from '/@/components/Modal';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { useRouter } from 'vue-router';
+  import {
+    fetchCheckSparkAppStart,
+    fetchSparkAppForcedStop,
+    fetchSparkAppStart,
+  } from '/@/api/spark/app';
+  import { AppExistsStateEnum } from '/@/enums/sparkEnum';
+  defineOptions({
+    name: 'StartApplicationModal',
+  });
+  const SelectOption = Select.Option;
+
+  const { t } = useI18n();
+  const { Swal } = useMessage();
+  const router = useRouter();
+  const selectInput = ref<boolean>(false);
+  const selectValue = ref<string | null>(null);
+
+  const emits = defineEmits(['register', 'updateOption']);
+  const receiveData = reactive<Recordable>({});
+
+  const [registerModal, { closeModal }] = useModalInner((data) => {
+    if (data) {
+      Object.assign(receiveData, data);
+      resetFields();
+      setFieldsValue({
+        savepointPath: receiveData.selected?.path,
+      });
+    }
+  });
+
+  function handleSavePointTip(list) {
+    if (list != null && list.length > 0) {
+      return t('flink.app.view.savepointSwitch');
+    }
+    return t('flink.app.view.savepointInput');
+  }
+
+  const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
+    name: 'startApplicationModal',
+    labelWidth: 120,
+    schemas: [
+      {
+        field: 'restoreSavepoint',
+        label: t('flink.app.view.fromSavepoint'),
+        component: 'Switch',
+        componentProps: {
+          checkedChildren: 'ON',
+          unCheckedChildren: 'OFF',
+        },
+        defaultValue: true,
+        afterItem: () => h('span', { class: 'conf-switch' }, 
t('flink.app.view.savepointTip')),
+      },
+      {
+        field: 'savepointPath',
+        label: 'Savepoint',
+        component:
+          receiveData.historySavePoint && receiveData.historySavePoint.length 
> 0
+            ? 'Select'
+            : 'Input',
+        afterItem: () =>
+          h('span', { class: 'conf-switch' }, 
handleSavePointTip(receiveData.historySavePoint)),
+        slot: 'savepoint',
+        ifShow: ({ values }) => values.restoreSavepoint,
+        required: true,
+      },
+      {
+        field: 'allowNonRestoredState',
+        label: t('flink.app.view.ignoreRestored'),
+        component: 'Switch',
+        componentProps: {
+          checkedChildren: 'ON',
+          unCheckedChildren: 'OFF',
+        },
+        afterItem: () => h('span', { class: 'conf-switch' }, 
t('flink.app.view.ignoreRestoredTip')),
+        defaultValue: false,
+        ifShow: ({ values }) => values.restoreSavepoint,
+      },
+    ],
+    colon: true,
+    showActionButtonGroup: false,
+    labelCol: { lg: { span: 7, offset: 0 }, sm: { span: 7, offset: 0 } },
+    wrapperCol: { lg: { span: 16, offset: 0 }, sm: { span: 4, offset: 0 } },
+    baseColProps: { span: 24 },
+  });
+
+  async function handleSubmit() {
+    // when then app is building, show forced starting modal
+    const resp = await fetchCheckSparkAppStart({
+      id: receiveData.application.id,
+    });
+    if (+resp === AppExistsStateEnum.IN_YARN) {
+      await fetchSparkAppForcedStop({
+        id: receiveData.application.id,
+      });
+    }
+    await handleDoSubmit();
+  }
+
+  async function handleReset() {
+    selectInput.value = false;
+    selectValue.value = null;
+  }
+
+  /* submit */
+  async function handleDoSubmit() {
+    try {
+      const formValue = (await validate()) as Recordable;
+      const restoreOrTriggerSavepoint = formValue.restoreSavepoint;
+      const savepointPath = restoreOrTriggerSavepoint ? 
formValue['savepointPath'] : null;
+      handleReset();
+      const res = await fetchSparkAppStart({
+        id: receiveData.application.id,
+        restoreOrTriggerSavepoint,
+        savepointPath: savepointPath,
+        allowNonRestored: formValue.allowNonRestoredState || false,
+      });
+      if (res.data) {
+        Swal.fire({
+          icon: 'success',
+          title: t('flink.app.operation.starting'),
+          showConfirmButton: false,
+          timer: 2000,
+        });
+        emits('updateOption', {
+          type: 'starting',
+          key: receiveData.application.id,
+          value: new Date().getTime(),
+        });
+        closeModal();
+      } else {
+        closeModal();
+        Swal.fire({
+          title: 'Failed',
+          icon: 'error',
+          width: exceptionPropWidth(),
+          html:
+            '<pre class="api-exception"> startup failed, ' +
+            res.message.replaceAll(/\[StreamPark]/g, '') +
+            '</pre>',
+          showCancelButton: true,
+          confirmButtonColor: '#55BDDDFF',
+          confirmButtonText: 'Detail',
+          cancelButtonText: 'Close',
+        }).then((isConfirm: Recordable) => {
+          if (isConfirm.value) {
+            router.push({
+              path: '/flink/app/detail',
+              query: { appId: receiveData.application.id },
+            });
+          }
+        });
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  function handleSavepoint(model, field, input) {
+    selectInput.value = input;
+    if (input) {
+      selectValue.value = model[field];
+      model[field] = null;
+    } else {
+      model[field] = selectValue.value;
+    }
+  }
+</script>
+<template>
+  <BasicModal
+    @register="registerModal"
+    :minHeight="100"
+    @ok="handleSubmit"
+    @cancel="handleReset"
+    :okText="t('common.apply')"
+    :cancelText="t('common.cancelText')"
+  >
+    <template #title>
+      <SvgIcon name="play" />
+      {{ t('flink.app.view.start') }}
+    </template>
+
+    <BasicForm @register="registerForm" class="!pt-40px">
+      <template #savepoint="{ model, field }">
+        <template
+          v-if="
+            !selectInput && receiveData.historySavePoint && 
receiveData.historySavePoint.length > 0
+          "
+        >
+          <Select v-model:value="model[field]" 
@dblclick="handleSavepoint(model, field, true)">
+            <SelectOption v-for="(k, i) in receiveData.historySavePoint" 
:key="i" :value="k.path">
+              <span style="color: darkgrey">
+                <Icon icon="ant-design:clock-circle-outlined" />
+                {{ k.createTime }}
+              </span>
+              <span style="float: left" v-if="k.type === 1">
+                <tag color="cyan">CP</tag>
+              </span>
+              <span style="float: right" v-else>
+                <tag color="blue">SP</tag>
+              </span>
+              <span style="float: right" v-if="k.latest">
+                <tag color="#2db7f5">latest</tag>
+              </span>
+            </SelectOption>
+          </Select>
+        </template>
+        <Input
+          v-else
+          @dblclick="handleSavepoint(model, field, false)"
+          type="text"
+          :placeholder="t('flink.app.view.savepointInput')"
+          v-model:value="model[field]"
+        />
+      </template>
+    </BasicForm>
+  </BasicModal>
+</template>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/Mergely.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/Mergely.vue
new file mode 100644
index 000000000..999871bea
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/Mergely.vue
@@ -0,0 +1,202 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts">
+  import { useDiffMonaco } from '/@/views/flink/app/hooks/useDiffMonaco';
+  import { useMonaco } from '/@/hooks/web/useMonaco';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { nextTick, Ref, ref, unref } from 'vue';
+  import { getMonacoOptions } from '../data';
+  import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
+  import { SvgIcon, Icon } from '/@/components/Icon';
+  const emit = defineEmits(['ok', 'close', 'register']);
+  const props = defineProps({
+    readOnly: {
+      type: Boolean as PropType<boolean>,
+      default: false,
+    },
+  });
+
+  const { t } = useI18n();
+  const title = ref('edit configuration');
+  const compareMode = ref(false);
+  const visibleDiff = ref(false);
+  const changed = ref(false);
+  const targetValue = ref<Nullable<string>>(null);
+  const originalValue = ref<Nullable<string>>(null);
+
+  const monacoMergely = ref();
+  const monacoConfig = ref();
+  const loading = ref(false);
+
+  /* Click Next */
+  function handleNext() {
+    visibleDiff.value = true;
+    title.value = 'Compare configuration';
+    nextTick(() => {
+      handleHeight(monacoMergely, 100);
+    });
+    // handleDifferent(unref(originalValue), unref(targetValue));
+  }
+  useDiffMonaco(
+    monacoMergely,
+    'yaml',
+    () => unref(originalValue),
+    () => unref(targetValue),
+    getMonacoOptions(props.readOnly) as any,
+  );
+
+  const { setContent, onChange } = useMonaco(monacoConfig, {
+    language: 'yaml',
+    options: getMonacoOptions(props.readOnly) as any,
+  });
+  onChange((value) => {
+    // the first time
+    if (targetValue.value) {
+      changed.value = true;
+    }
+    targetValue.value = value;
+  });
+
+  /* Change editor height */
+  function handleHeight(ele: Ref, h: number) {
+    const height = document.documentElement.offsetHeight || 
document.body.offsetHeight;
+    unref(ele).style.height = height - h + 'px';
+  }
+
+  /* close match */
+  function handleCloseDiff() {
+    title.value = 'Edit configuration';
+    visibleDiff.value = false;
+  }
+
+  /* Click OK */
+  function handleOk() {
+    const value = unref(targetValue);
+    if (value == null || !value.replace(/^\s+|\s+$/gm, '')) {
+      emit('ok', { isSetConfig: false, configOverride: null });
+    } else {
+      emit('ok', { isSetConfig: true, configOverride: value });
+    }
+
+    handleCancel();
+  }
+
+  /* Click to cancel */
+  function handleCancel() {
+    changed.value = false;
+    targetValue.value = null;
+    originalValue.value = null;
+    visibleDiff.value = false;
+    loading.value = false;
+    emit('close');
+    closeDrawer();
+  }
+  const [registerMergelyDrawer, { closeDrawer }] = useDrawerInner(
+    (data: { configOverride: string }) => {
+      data && onReceiveDrawerData(data);
+    },
+  );
+  /* data reception */
+  function onReceiveDrawerData(data) {
+    compareMode.value = false;
+    changed.value = false;
+    targetValue.value = null;
+    originalValue.value = data.configOverride;
+    if (props.readOnly) {
+      title.value = 'Configuration detail';
+    }
+    nextTick(() => {
+      handleHeight(unref(monacoConfig), 130);
+      visibleDiff.value = false;
+      setContent(unref(originalValue) || '');
+    });
+  }
+</script>
+
+<template>
+  <BasicDrawer
+    @register="registerMergelyDrawer"
+    :keyboard="false"
+    :closable="false"
+    :mask-closable="false"
+    width="80%"
+    class="drawer-conf"
+  >
+    <template #title>
+      <SvgIcon v-if="props.readOnly" name="see" />
+      <SvgIcon v-else name="edit" />
+      {{ title }}
+    </template>
+    <div v-show="!visibleDiff">
+      <div ref="monacoConfig"></div>
+      <div class="drawer-bottom-button">
+        <div style="float: right">
+          <a-button type="primary" class="drwaer-button-item" 
@click="handleCancel">
+            {{ t('common.cancelText') }}
+          </a-button>
+          <a-button v-if="changed" type="primary" class="drwaer-button-item" 
@click="handleNext()">
+            <Icon icon="clarity:note-edit-line" />
+
+            {{ t('common.next') }}
+          </a-button>
+        </div>
+      </div>
+    </div>
+
+    <div v-if="visibleDiff">
+      <div ref="monacoMergely"></div>
+      <div class="drawer-bottom-button" style="position: absolute">
+        <div style="float: right">
+          <a-button
+            v-if="changed"
+            type="primary"
+            class="drwaer-button-item"
+            @click="handleCloseDiff"
+          >
+            <Icon icon="ant-design:left-outlined" />
+
+            {{ t('common.previous') }}
+          </a-button>
+          <a-button v-if="!compareMode" class="drwaer-button-item" 
type="primary" @click="handleOk">
+            <Icon icon="ant-design:cloud-outlined" />
+
+            {{ t('common.apply') }}
+          </a-button>
+        </div>
+      </div>
+    </div>
+  </BasicDrawer>
+</template>
+<style scoped>
+  .drawer-conf :deep(.ant-drawer-body) {
+    padding: 5px !important;
+    padding-bottom: 0px !important;
+  }
+
+  .drawer-bottom-button {
+    position: absolute;
+    padding-top: 10px;
+    padding-right: 50px;
+    width: 100%;
+    bottom: 10px;
+    z-index: 9;
+  }
+
+  .drwaer-button-item {
+    margin-right: 20px;
+  }
+</style>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/ProgramArgs.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/ProgramArgs.vue
new file mode 100644
index 000000000..0da0d6366
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/ProgramArgs.vue
@@ -0,0 +1,128 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<template>
+  <div style="height: 340px" :class="fullContentClass">
+    <div
+      class="full-content-tool flex justify-between px-20px border-solid 
border-b pb-10px mb-10px"
+      v-if="fullScreenStatus"
+    >
+      <div class="basic-title">
+        <Icon icon="material-symbols:energy-program-saving" color="#477de9" />
+        Program args
+      </div>
+      <Tooltip :title="t('component.modal.restore')" placement="bottom">
+        <FullscreenExitOutlined role="full" @click="toggle" style="font-size: 
18px" />
+      </Tooltip>
+    </div>
+    <div ref="programArgRef" :class="fullEditorClass" class="w-full 
program-box mt-5px"> </div>
+    <ButtonGroup class="flinksql-tool" v-if="!fullScreenStatus">
+      <a-button
+        class="flinksql-tool-item"
+        v-if="canReview"
+        type="primary"
+        @click="emit('preview', value)"
+        size="small"
+      >
+        <Icon icon="ant-design:eye-outlined" />
+        {{ t('flink.app.flinkSql.preview') }}
+      </a-button>
+      <a-button
+        class="flinksql-tool-item"
+        size="small"
+        :type="canReview ? 'default' : 'primary'"
+        @click="toggle"
+      >
+        <Icon icon="ant-design:fullscreen-outlined" />
+        {{ t('layout.header.tooltipEntryFull') }}
+      </a-button>
+    </ButtonGroup>
+    <ButtonGroup v-else class="flinksql-tool">
+      <a-button
+        type="primary"
+        class="flinksql-tool-item"
+        v-if="canReview"
+        @click="emit('preview', value)"
+      >
+        <Icon icon="ant-design:eye-outlined" />
+        {{ t('flink.app.flinkSql.preview') }}
+      </a-button>
+      <a-button
+        class="flinksql-tool-item"
+        size="small"
+        :type="canReview ? 'default' : 'primary'"
+        @click="toggle"
+      >
+        <Icon icon="ant-design:fullscreen-exit-outlined" />
+        {{ t('layout.header.tooltipExitFull') }}
+      </a-button>
+    </ButtonGroup>
+  </div>
+</template>
+<script lang="ts" setup>
+  import { Button, Tooltip } from 'ant-design-vue';
+  import { FullscreenExitOutlined } from '@ant-design/icons-vue';
+  import { computed, ref, toRefs, watchEffect } from 'vue';
+  import { getMonacoOptions } from '../data';
+  import Icon from '/@/components/Icon';
+  import { useFullContent } from '/@/hooks/event/useFullscreen';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { useMonaco } from '/@/hooks/web/useMonaco';
+  const { t } = useI18n();
+  const ButtonGroup = Button.Group;
+
+  const props = defineProps({
+    value: {
+      type: String,
+      required: true,
+    },
+    suggestions: {
+      type: Array as PropType<Array<{ text: string; description: string; 
value: string }>>,
+      default: () => [],
+    },
+  });
+  const { value, suggestions } = toRefs(props);
+  const emit = defineEmits(['update:value', 'preview']);
+  const programArgRef = ref();
+
+  const { toggle, fullContentClass, fullEditorClass, fullScreenStatus } = 
useFullContent();
+  const { onChange, setContent, setMonacoSuggest } = useMonaco(programArgRef, {
+    language: 'plaintext',
+    code: '',
+    options: {
+      ...(getMonacoOptions(false) as any),
+      autoClosingBrackets: 'never',
+    },
+  });
+  watchEffect(() => {
+    if (suggestions.value.length > 0) {
+      setMonacoSuggest(suggestions.value);
+    }
+  });
+  const canReview = computed(() => {
+    return /\${.+}/.test(value.value);
+  });
+
+  onChange((data) => {
+    emit('update:value', data);
+  });
+  defineExpose({ setContent });
+</script>
+<style lang="less">
+  .program-box {
+    border: 1px solid @border-color-base;
+  }
+</style>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/SparkSql.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/SparkSql.vue
new file mode 100644
index 000000000..3a6bc71b4
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/SparkSql.vue
@@ -0,0 +1,301 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<script setup lang="ts">
+  import { computed, reactive, ref, watchEffect } from 'vue';
+  import { Tooltip } from 'ant-design-vue';
+  import { FullscreenExitOutlined } from '@ant-design/icons-vue';
+  import { getMonacoOptions } from '../data';
+  import { Icon, SvgIcon } from '/@/components/Icon';
+  import { useMonaco } from '/@/hooks/web/useMonaco';
+  import { Button } from 'ant-design-vue';
+  import { isEmpty } from '/@/utils/is';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { format } from '../sqlFormatter';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { useFullContent } from '/@/hooks/event/useFullscreen';
+  import { fetchSparkSqlVerify } from '/@/api/spark/sql';
+  defineOptions({
+    name: 'SparkSQL',
+  });
+  const ButtonGroup = Button.Group;
+  const { t } = useI18n();
+
+  const flinkSql = ref();
+  const verifyRes = reactive({
+    errorMsg: '',
+    verified: false,
+    errorStart: 0,
+    errorEnd: 0,
+  });
+
+  const { toggle, fullContentClass, fullEditorClass, fullScreenStatus } = 
useFullContent();
+  const emit = defineEmits(['update:value', 'preview']);
+  const { createMessage } = useMessage();
+
+  const props = defineProps({
+    value: {
+      type: String,
+      default: '',
+    },
+    appId: {
+      type: String as PropType<Nullable<string>>,
+    },
+    versionId: {
+      type: String as PropType<Nullable<string>>,
+    },
+    suggestions: {
+      type: Array as PropType<Array<{ text: string; description: string }>>,
+      default: () => [],
+    },
+  });
+  const defaultValue = '';
+
+  /* verify */
+  async function handleVerifySql() {
+    if (isEmpty(props.value)) {
+      verifyRes.errorMsg = 'empty sql';
+      return false;
+    }
+
+    if (!props.versionId) {
+      createMessage.error(t('flink.app.dependencyError'));
+      return false;
+    } else {
+      try {
+        const { data } = await fetchSparkSqlVerify({
+          sql: props.value,
+          versionId: props.versionId,
+        });
+        const success = data.data === true || data.data === 'true';
+        if (success) {
+          verifyRes.verified = true;
+          verifyRes.errorMsg = '';
+          syntaxError();
+          return true;
+        } else {
+          verifyRes.errorStart = parseInt(data.start);
+          verifyRes.errorEnd = parseInt(data.end);
+          switch (data.type) {
+            case 4:
+              verifyRes.errorMsg = 'Unsupported sql';
+              break;
+            case 5:
+              verifyRes.errorMsg = "SQL is not endWith ';'";
+              break;
+            default:
+              verifyRes.errorMsg = data.message;
+              break;
+          }
+          syntaxError();
+          return false;
+        }
+      } catch (error) {
+        console.error(error);
+        return false;
+      }
+    }
+  }
+
+  async function syntaxError() {
+    const editor = await getInstance();
+    if (editor) {
+      const model = editor.getModel();
+      const monaco = await getMonacoInstance();
+      if (verifyRes.errorMsg) {
+        try {
+          monaco.editor.setModelMarkers(model, 'sql', [
+            {
+              startLineNumber: verifyRes.errorStart,
+              endLineNumber: verifyRes.errorEnd,
+              severity: monaco.MarkerSeverity.Error,
+              message: verifyRes.errorMsg,
+            },
+          ]);
+        } catch (e) {
+          console.log(e);
+        }
+      } else {
+        monaco.editor.setModelMarkers(model, 'sql', []);
+      }
+    }
+  }
+  /* format */
+  function handleFormatSql() {
+    if (isEmpty(props.value)) return;
+    const formatSql = format(props.value);
+    setContent(formatSql);
+  }
+  /* full screen */
+  // function handleBigScreen() {
+  //   toggle();
+  //   unref(flinkSql).style.width = '0';
+  //   setTimeout(() => {
+  //     unref(flinkSql).style.width = '100%';
+  //     unref(flinkSql).style.height = isFullscreen.value ? 'calc(100vh - 
50px)' : '550px';
+  //   }, 500);
+  // }
+  const { onChange, setContent, getInstance, getMonacoInstance, 
setMonacoSuggest } = useMonaco(
+    flinkSql,
+    {
+      language: 'sql',
+      code: props.value || defaultValue,
+      options: {
+        minimap: { enabled: true },
+        ...(getMonacoOptions(false) as any),
+        autoClosingBrackets: 'never',
+      },
+    },
+  );
+
+  watchEffect(() => {
+    if (props.suggestions.length > 0) {
+      setMonacoSuggest(props.suggestions);
+    }
+  });
+  const canPreview = computed(() => {
+    return /\${.+}/.test(props.value);
+  });
+  const flinkEditorClass = computed(() => {
+    return {
+      ...fullEditorClass.value,
+      ['syntax-' + (verifyRes.errorMsg ? 'false' : 'true')]: true,
+    };
+  });
+
+  onChange((data) => {
+    emit('update:value', data);
+  });
+
+  defineExpose({ handleVerifySql, setContent });
+</script>
+
+<template>
+  <div class="w-full h-550px" :class="fullContentClass">
+    <div
+      class="full-content-tool flex justify-between px-20px pb-10px mb-10px"
+      v-if="fullScreenStatus"
+    >
+      <div class="flex items-center">
+        <SvgIcon name="fql" />
+        <div class="basic-title ml-10px">Spark Sql</div>
+      </div>
+      <Tooltip :title="t('component.modal.restore')" placement="bottom">
+        <FullscreenExitOutlined role="full" @click="toggle" style="font-size: 
18px" />
+      </Tooltip>
+    </div>
+
+    <div
+      ref="flinkSql"
+      class="overflow-hidden w-full mt-5px sql-bordered"
+      :class="flinkEditorClass"
+    ></div>
+    <ButtonGroup class="sql-tool" v-if="!fullScreenStatus">
+      <a-button size="small" class="sql-tool-item" type="primary" 
@click="handleVerifySql">
+        <Icon icon="ant-design:check-outlined" />
+        {{ t('flink.app.flinkSql.verify') }}
+      </a-button>
+      <a-button
+        class="sql-tool-item"
+        size="small"
+        type="default"
+        v-if="canPreview"
+        @click="emit('preview', value)"
+      >
+        <Icon icon="ant-design:eye-outlined" />
+        {{ t('flink.app.flinkSql.preview') }}
+      </a-button>
+      <a-button class="sql-tool-item" size="small" type="default" 
@click="handleFormatSql">
+        <Icon icon="ant-design:thunderbolt-outlined" />
+        {{ t('flink.app.flinkSql.format') }}
+      </a-button>
+      <a-button class="sql-tool-item" size="small" type="default" 
@click="toggle">
+        <Icon icon="ant-design:fullscreen-outlined" />
+        {{ t('flink.app.flinkSql.fullScreen') }}
+      </a-button>
+    </ButtonGroup>
+    <div class="flex items-center justify-between" v-else>
+      <div class="mt-10px flex-1 mr-10px overflow-hidden whitespace-nowrap">
+        <div class="text-red-600 overflow-ellipsis overflow-hidden" 
v-if="verifyRes.errorMsg">
+          {{ verifyRes.errorMsg }}
+        </div>
+        <div v-else class="text-green-700">
+          <span v-if="verifyRes.verified"> {{ 
t('flink.app.flinkSql.successful') }} </span>
+        </div>
+      </div>
+      <div class="sql-tool">
+        <a-button type="primary" @click="handleVerifySql">
+          <div class="flex items-center">
+            <Icon icon="ant-design:check-outlined" />
+            {{ t('flink.app.flinkSql.verify') }}
+          </div>
+        </a-button>
+        <a-button v-if="canPreview" @click="emit('preview', value)" 
class="ml-10px">
+          <div class="flex items-center">
+            <Icon icon="ant-design:eye-outlined" />
+            {{ t('flink.app.flinkSql.preview') }}
+          </div>
+        </a-button>
+        <a-button type="default" @click="handleFormatSql" class="ml-10px">
+          <div class="flex items-center">
+            <Icon icon="ant-design:thunderbolt-outlined" />
+            {{ t('flink.app.flinkSql.format') }}
+          </div>
+        </a-button>
+        <a-button type="default" @click="toggle" class="ml-10px">
+          <div class="flex items-center">
+            <Icon icon="ant-design:fullscreen-exit-outlined" />
+            {{ t('layout.header.tooltipExitFull') }}
+          </div>
+        </a-button>
+      </div>
+    </div>
+  </div>
+  <p class="conf-desc mt-10px" v-if="!fullScreenStatus">
+    <span class="text-red-600" v-if="verifyRes.errorMsg"> {{ 
verifyRes.errorMsg }} </span>
+    <span v-else class="text-green-700">
+      <span v-if="verifyRes.verified"> {{ t('flink.app.flinkSql.successful') 
}} </span>
+    </span>
+  </p>
+</template>
+<style lang="less" scoped>
+  .full-content-tool {
+    border-bottom: 1px solid @border-color-base;
+  }
+  .sql-bordered {
+    border: 1px solid @border-color-base;
+    border-radius: 4px;
+  }
+
+  .sql-tool {
+    z-index: 99;
+    float: right;
+    margin-right: 5px;
+    cursor: pointer;
+    margin-top: 5px;
+  }
+
+  .sql-tool-item {
+    font-size: 12px;
+    display: flex;
+    align-items: center;
+  }
+  .conf-desc {
+    color: darkgrey;
+    margin-bottom: 0;
+  }
+</style>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/StatisticCard.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/StatisticCard.vue
new file mode 100644
index 000000000..2c2247401
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/StatisticCard.vue
@@ -0,0 +1,54 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts" name="StatisticCard">
+  import { Card, Statistic, Divider, Skeleton } from 'ant-design-vue';
+  defineProps({
+    loading: { type: Boolean, default: false },
+    statisticProps: {
+      type: Object as PropType<Recordable>,
+      default: () => ({ title: '', value: 0 }),
+    },
+    footerList: {
+      type: Array as PropType<Array<{ title: string; value: string | number 
}>>,
+      default: () => [],
+    },
+  });
+</script>
+<template>
+  <div class="gutter-box">
+    <Skeleton :loading="loading" active>
+      <Card :bordered="false" class="dash-statistic">
+        <Statistic
+          v-bind="statisticProps"
+          :value-style="{
+            color: '#3f8600',
+            fontSize: '45px',
+            fontWeight: 500,
+            textShadow: '1px 1px 0 rgba(0,0,0,0.2)',
+          }"
+        />
+      </Card>
+      <Divider class="def-margin-bottom" />
+      <template v-for="(item, index) in footerList" :key="index">
+        <span> {{ item.title }} </span>
+        <strong class="pl-10px">{{ item.value }}</strong>
+        <Divider type="vertical" v-if="index !== footerList.length - 1" />
+      </template>
+    </Skeleton>
+  </div>
+</template>
+<style scoped></style>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/components/VariableReview.vue
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/VariableReview.vue
new file mode 100644
index 000000000..3022a4361
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/components/VariableReview.vue
@@ -0,0 +1,65 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<template>
+  <BasicDrawer
+    @register="registerDrawer"
+    :width="800"
+    placement="right"
+    :showOkBtn="false"
+    showFooter
+  >
+    <template #title>
+      <EyeOutlined style="color: green" />
+      Flink SQL preview
+    </template>
+    <div ref="flinkReviewRef" class="h-[calc(100vh-150px)] 
flink-preview"></div>
+  </BasicDrawer>
+</template>
+
+<script lang="ts" setup>
+  import { EyeOutlined } from '@ant-design/icons-vue';
+  import { ref } from 'vue';
+  import { getMonacoOptions } from '../data';
+  import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
+  import { useMonaco } from '/@/hooks/web/useMonaco';
+  const flinkReviewRef = ref();
+  const { setContent } = useMonaco(flinkReviewRef, {
+    language: 'sql',
+    code: '',
+    options: getMonacoOptions(true) as any,
+  });
+  const [registerDrawer] = useDrawerInner((data) => {
+    if (data) {
+      const suggestions = data.suggestions.reduce((pre: Recordable, cur: 
Recordable) => {
+        pre[cur.text] = cur.value;
+        return pre;
+      }, {});
+      console.log('suggestions', suggestions);
+      const content = data.value.replace(
+        /\${(.*?)}/g,
+        (node: string, $1: string) => suggestions[$1] || node,
+      );
+
+      setContent(content || '');
+    }
+  });
+</script>
+<style lang="less">
+  .flink-preview {
+    border: 1px solid @border-color-base;
+  }
+</style>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/create.vue 
b/streampark-console/streampark-console-webapp/src/views/spark/app/create.vue
new file mode 100644
index 000000000..8c714de26
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/create.vue
@@ -0,0 +1,201 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts">
+  import { useGo } from '/@/hooks/web/usePage';
+  import ProgramArgs from './components/ProgramArgs.vue';
+  import { onMounted, ref } from 'vue';
+  import { PageWrapper } from '/@/components/Page';
+  import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
+
+  import { BasicForm, useForm } from '/@/components/Form';
+  import { useDrawer } from '/@/components/Drawer';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { createLocalStorage } from '/@/utils/cache';
+  import { buildUUID } from '/@/utils/uuid';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import VariableReview from './components/VariableReview.vue';
+  import { encryptByBase64 } from '/@/utils/cipher';
+  import { AppTypeEnum, JobTypeEnum, ResourceFromEnum } from 
'/@/enums/flinkEnum';
+  import { useSparkSchema } from './hooks/useAppFormSchema';
+  import { fetchCreateSparkApp } from '/@/api/spark/app';
+  import { SparkApplication } from '/@/api/spark/app.type';
+
+  const SparkSqlEditor = createAsyncComponent(() => 
import('./components/SparkSql.vue'), {
+    loading: true,
+  });
+
+  defineOptions({
+    name: 'SparkApplicationAction',
+  });
+  const go = useGo();
+  const sparkSql = ref();
+  const submitLoading = ref(false);
+
+  const { t } = useI18n();
+  const { createMessage } = useMessage();
+  const ls = createLocalStorage();
+
+  const { formSchema, sparkEnvs, suggestions } = useSparkSchema();
+
+  const [registerAppForm, { setFieldsValue, submit }] = useForm({
+    labelCol: { lg: { span: 5, offset: 0 }, sm: { span: 7, offset: 0 } },
+    wrapperCol: { lg: { span: 16, offset: 0 }, sm: { span: 17, offset: 0 } },
+    baseColProps: { span: 24 },
+    colon: true,
+    showActionButtonGroup: false,
+  });
+
+  const [registerReviewDrawer, { openDrawer: openReviewDrawer }] = useDrawer();
+
+  /* Initialize the form */
+  async function handleInitForm() {
+    const defaultValue = {};
+    const v = sparkEnvs.value.filter((v) => v.isDefault)[0];
+    if (v) {
+      Object.assign(defaultValue, { versionId: v.id });
+    }
+    await setFieldsValue(defaultValue);
+  }
+
+  /* custom mode */
+  async function handleCustomJobMode(values: Recordable) {
+    const params = {
+      jobType: JobTypeEnum.SQL,
+      executionMode: values.executionMode,
+      appType: AppTypeEnum.APACHE_SPARK,
+      versionId: values.versionId,
+      sparkSql: null,
+      jar: values.teamResource,
+      mainClass: values.mainClass,
+      appName: values.jobName,
+      tags: values.tags,
+      yarnQueue: values.yarnQueue,
+      resourceFrom: ResourceFromEnum.UPLOAD,
+      config: null,
+      appProperties: values.appProperties,
+      appArgs: values.args,
+      hadoopUser: values.hadoopUser,
+      description: values.description,
+    };
+    handleCreateAction(params);
+  }
+  /* spark sql mode */
+  async function handleSQLMode(values: Recordable) {
+    let config = values.configOverride;
+    if (config != null && config !== undefined && config.trim() != '') {
+      config = encryptByBase64(config);
+    } else {
+      config = null;
+    }
+    handleCreateAction({
+      jobType: JobTypeEnum.SQL,
+      executionMode: values.executionMode,
+      appType: AppTypeEnum.APACHE_SPARK,
+      versionId: values.versionId,
+      sparkSql: values.sparkSql,
+      jar: null,
+      mainClass: null,
+      appName: values.jobName,
+      tags: values.tags,
+      yarnQueue: values.yarnQueue,
+      resourceFrom: ResourceFromEnum.UPLOAD,
+      config,
+      appProperties: values.appProperties,
+      appArgs: values.args,
+      hadoopUser: values.hadoopUser,
+      description: values.description,
+    });
+  }
+  /* Submit to create */
+  async function handleAppSubmit(formValue: Recordable) {
+    try {
+      submitLoading.value = true;
+      if (formValue.jobType == JobTypeEnum.SQL) {
+        if (formValue.sparkSql == null || formValue.sparkSql.trim() === '') {
+          
createMessage.warning(t('flink.app.editStreamPark.sparkSqlRequired'));
+        } else {
+          const access = await sparkSql?.value?.handleVerifySql();
+          if (!access) {
+            createMessage.warning(t('flink.app.editStreamPark.sqlCheck'));
+            throw new Error(access);
+          }
+        }
+        handleSQLMode(formValue);
+      } else {
+        handleCustomJobMode(formValue);
+      }
+    } catch (error) {
+      submitLoading.value = false;
+    }
+  }
+  /* send create request */
+  async function handleCreateAction(params: Recordable) {
+    const param: SparkApplication = {};
+    for (const k in params) {
+      const v = params[k];
+      if (v != null && v !== undefined) {
+        param[k] = v;
+      }
+    }
+    const socketId = buildUUID();
+    ls.set('DOWN_SOCKET_ID', socketId);
+    Object.assign(param, { socketId });
+    await fetchCreateSparkApp(param);
+    submitLoading.value = false;
+    go('/spark/app');
+  }
+
+  onMounted(async () => {
+    handleInitForm();
+  });
+</script>
+
+<template>
+  <PageWrapper contentFullHeight contentBackground contentClass="p-26px 
app_controller">
+    <BasicForm @register="registerAppForm" @submit="handleAppSubmit" 
:schemas="formSchema">
+      <template #sparkSql="{ model, field }">
+        <SparkSqlEditor
+          ref="sparkSql"
+          v-model:value="model[field]"
+          :versionId="model['versionId']"
+          :suggestions="suggestions"
+          @preview="(value) => openReviewDrawer(true, { value, suggestions })"
+        />
+      </template>
+      <template #args="{ model }">
+        <template v-if="model.args !== undefined">
+          <ProgramArgs
+            v-model:value="model.args"
+            :suggestions="suggestions"
+            @preview="(value) => openReviewDrawer(true, { value, suggestions 
})"
+          />
+        </template>
+      </template>
+      <template #formFooter>
+        <div class="flex items-center w-full justify-center">
+          <a-button @click="go('/spark/app')">
+            {{ t('common.cancelText') }}
+          </a-button>
+          <a-button class="ml-4" :loading="submitLoading" type="primary" 
@click="submit()">
+            {{ t('common.submitText') }}
+          </a-button>
+        </div>
+      </template>
+    </BasicForm>
+    <VariableReview @register="registerReviewDrawer" />
+  </PageWrapper>
+</template>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/data/index.ts
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/data/index.ts
new file mode 100644
index 000000000..ba5f4d2af
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/data/index.ts
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { FailoverStrategyEnum, ReleaseStateEnum } from '/@/enums/flinkEnum';
+import { ExecModeEnum } from '/@/enums/sparkEnum';
+
+/* Get diff editor configuration */
+export const getMonacoOptions = (readOnly: boolean) => {
+  return {
+    selectOnLineNumbers: false,
+    foldingStrategy: 'indentation', // code fragmentation
+    overviewRulerBorder: false, // Don't scroll bar borders
+    autoClosingBrackets: 'always',
+    autoClosingDelete: 'always',
+    tabSize: 2, // tab indent length
+    readOnly,
+    inherit: true,
+    scrollBeyondLastLine: false,
+    lineNumbersMinChars: 5,
+    lineHeight: 24,
+    automaticLayout: true,
+    cursorBlinking: 'line',
+    cursorStyle: 'line',
+    cursorWidth: 3,
+    renderFinalNewline: true,
+    renderLineHighlight: 'all',
+    quickSuggestionsDelay: 100, // Code prompt delay
+    scrollbar: {
+      useShadows: false,
+      vertical: 'visible',
+      horizontal: 'visible',
+      horizontalSliderSize: 5,
+      verticalSliderSize: 5,
+      horizontalScrollbarSize: 15,
+      verticalScrollbarSize: 15,
+    },
+  };
+};
+
+export const resolveOrder = [
+  { label: 'parent-first', value: 0 },
+  { label: 'child-first', value: 1 },
+];
+
+export const k8sRestExposedType = [
+  { label: 'LoadBalancer', value: 0 },
+  { label: 'ClusterIP', value: 1 },
+  { label: 'NodePort', value: 2 },
+];
+
+export const executionModes = [
+  { label: 'Standalone', value: ExecModeEnum.REMOTE, disabled: false },
+  { label: 'Yarn-Cluster', value: ExecModeEnum.YARN_CLUSTER, disabled: false },
+  { label: 'Yarn-Client', value: ExecModeEnum.YARN_CLIENT, disabled: false },
+];
+
+export const cpTriggerAction = [
+  { label: 'alert', value: FailoverStrategyEnum.ALERT },
+  { label: 'restart', value: FailoverStrategyEnum.RESTART },
+];
+
+export const releaseTitleMap = {
+  [ReleaseStateEnum.FAILED]: 'release failed',
+  [ReleaseStateEnum.NEED_RELEASE]: 'current job need release',
+  [ReleaseStateEnum.RELEASING]: 'releasing',
+  [ReleaseStateEnum.NEED_RESTART]: 'release finished,need restart',
+  [ReleaseStateEnum.NEED_ROLLBACK]: 'application is rollbacked,need release',
+};
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useAppFormSchema.tsx
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useAppFormSchema.tsx
new file mode 100644
index 000000000..b4ca02eb6
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useAppFormSchema.tsx
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { computed, h, onMounted, ref, unref } from 'vue';
+import type { FormSchema } from '/@/components/Form';
+import { useI18n } from '/@/hooks/web/useI18n';
+import { AppExistsStateEnum, JobTypeEnum } from '/@/enums/sparkEnum';
+import { ResourceFromEnum } from '/@/enums/flinkEnum';
+import { SvgIcon } from '/@/components/Icon';
+import type { SparkEnv } from '/@/api/spark/home.type';
+import type { RuleObject } from 'ant-design-vue/lib/form';
+import type { StoreValue } from 'ant-design-vue/lib/form/interface';
+import { renderIsSetConfig, renderStreamParkResource, renderYarnQueue } from 
'./useFlinkRender';
+import { executionModes } from '../data';
+import { useDrawer } from '/@/components/Drawer';
+import { fetchSparkEnvList } from '/@/api/spark/home';
+import { fetchVariableAll } from '/@/api/resource/variable';
+import { fetchTeamResource } from '/@/api/resource/upload';
+import { fetchCheckSparkName } from '/@/api/spark/app';
+export function useSparkSchema() {
+  const { t } = useI18n();
+  const sparkEnvs = ref<SparkEnv[]>([]);
+  const teamResource = ref<Array<any>>([]);
+  const suggestions = ref<Array<{ text: string; description: string; value: 
string }>>([]);
+
+  const [registerConfDrawer, { openDrawer: openConfDrawer }] = useDrawer();
+  /* Detect job name field */
+  async function getJobNameCheck(_rule: RuleObject, value: StoreValue, _model: 
Recordable) {
+    if (value === null || value === undefined || value === '') {
+      return 
Promise.reject(t('flink.app.addAppTips.appNameIsRequiredMessage'));
+    }
+    const params = { jobName: value };
+    // if (edit?.appId) {
+    //   Object.assign(params, { id: edit.appId });
+    // }
+    const res = await fetchCheckSparkName(params);
+    switch (parseInt(res)) {
+      case AppExistsStateEnum.NO:
+        return Promise.resolve();
+      case AppExistsStateEnum.IN_DB:
+        return 
Promise.reject(t('flink.app.addAppTips.appNameNotUniqueMessage'));
+      case AppExistsStateEnum.IN_YARN:
+        return 
Promise.reject(t('flink.app.addAppTips.appNameExistsInYarnMessage'));
+      case AppExistsStateEnum.IN_KUBERNETES:
+        return 
Promise.reject(t('flink.app.addAppTips.appNameExistsInK8sMessage'));
+      default:
+        return Promise.reject(t('flink.app.addAppTips.appNameValid'));
+    }
+  }
+  const getJobTypeOptions = () => {
+    return [
+      {
+        label: h('div', {}, [
+          h(SvgIcon, { name: 'code', color: '#108ee9' }, ''),
+          h('span', { class: 'pl-10px' }, 'Custom Code'),
+        ]),
+        value: String(JobTypeEnum.JAR),
+      },
+      {
+        label: h('div', {}, [
+          h(SvgIcon, { name: 'fql', color: '#108ee9' }, ''),
+          h('span', { class: 'pl-10px' }, 'Flink SQL'),
+        ]),
+        value: String(JobTypeEnum.SQL),
+      },
+      {
+        label: h('div', {}, [
+          h(SvgIcon, { name: 'py', color: '#108ee9' }, ''),
+          h('span', { class: 'pl-10px' }, 'Python Flink'),
+        ]),
+        value: String(JobTypeEnum.PYSPARK),
+      },
+    ];
+  };
+
+  const formSchema = computed((): FormSchema[] => {
+    return [
+      {
+        field: 'jobType',
+        label: t('flink.app.developmentMode'),
+        component: 'Select',
+        componentProps: ({ formModel }) => {
+          return {
+            placeholder: t('flink.app.addAppTips.developmentModePlaceholder'),
+            options: getJobTypeOptions(),
+            onChange: (value) => {
+              if (value != JobTypeEnum.SQL) {
+                formModel.resourceFrom = String(ResourceFromEnum.PROJECT);
+              }
+            },
+          };
+        },
+        defaultValue: String(JobTypeEnum.SQL),
+        rules: [
+          { required: true, message: 
t('flink.app.addAppTips.developmentModeIsRequiredMessage') },
+        ],
+      },
+      {
+        field: 'executionMode',
+        label: t('flink.app.executionMode'),
+        component: 'Select',
+        itemProps: {
+          autoLink: false, //Resolve multiple trigger validators with null 
value ·
+        },
+        componentProps: {
+          placeholder: t('flink.app.addAppTips.executionModePlaceholder'),
+          options: executionModes,
+        },
+        rules: [
+          {
+            required: true,
+            validator: async (_rule, value) => {
+              if (value === null || value === undefined || value === '') {
+                return 
Promise.reject(t('flink.app.addAppTips.executionModeIsRequiredMessage'));
+              } else {
+                return Promise.resolve();
+              }
+            },
+          },
+        ],
+      },
+      {
+        field: 'versionId',
+        label: 'Spark Version',
+        component: 'Select',
+        componentProps: {
+          placeholder: 'Spark Version',
+          options: unref(sparkEnvs),
+          fieldNames: { label: 'sparkName', value: 'id', options: 'options' },
+        },
+        rules: [
+          { required: true, message: 
t('flink.app.addAppTips.flinkVersionIsRequiredMessage') },
+        ],
+      },
+      {
+        field: 'sparkSql',
+        label: 'Spark SQL',
+        component: 'Input',
+        slot: 'sparkSql',
+        ifShow: ({ values }) => values?.jobType == JobTypeEnum.SQL,
+        rules: [{ required: true, message: 
t('flink.app.addAppTips.flinkSqlIsRequiredMessage') }],
+      },
+      {
+        field: 'teamResource',
+        label: t('flink.app.resource'),
+        component: 'Select',
+        render: ({ model }) => renderStreamParkResource({ model, resources: 
unref(teamResource) }),
+        ifShow: ({ values }) => values.jobType == JobTypeEnum.JAR,
+      },
+      {
+        field: 'mainClass',
+        label: t('flink.app.mainClass'),
+        component: 'Input',
+        componentProps: { placeholder: 
t('flink.app.addAppTips.mainClassPlaceholder') },
+        ifShow: ({ values }) => values?.jobType == JobTypeEnum.JAR,
+        rules: [{ required: true, message: 
t('flink.app.addAppTips.mainClassIsRequiredMessage') }],
+      },
+      {
+        field: 'jobName',
+        label: t('flink.app.appName'),
+        component: 'Input',
+        componentProps: { placeholder: 
t('flink.app.addAppTips.appNamePlaceholder') },
+        dynamicRules: ({ model }) => {
+          return [
+            {
+              required: true,
+              trigger: 'blur',
+              validator: (rule: RuleObject, value: StoreValue) =>
+                getJobNameCheck(rule, value, model),
+            },
+          ];
+        },
+      },
+      {
+        field: 'tags',
+        label: t('flink.app.tags'),
+        component: 'Input',
+        componentProps: {
+          placeholder: t('flink.app.addAppTips.tagsPlaceholder'),
+        },
+      },
+      {
+        field: 'yarnQueue',
+        label: t('flink.app.yarnQueue'),
+        component: 'Input',
+        render: (renderCallbackParams) => 
renderYarnQueue(renderCallbackParams),
+      },
+      {
+        field: 'isSetConfig',
+        label: t('flink.app.appConf'),
+        component: 'Switch',
+        render({ model, field }) {
+          return renderIsSetConfig(model, field, registerConfDrawer, 
openConfDrawer);
+        },
+      },
+      {
+        field: 'appProperties',
+        label: 'Application Properties',
+        component: 'InputTextArea',
+        componentProps: {
+          rows: 4,
+          placeholder:
+            '$key=$value,If there are multiple parameters,you can new line 
enter them (-D <arg>)',
+        },
+      },
+      {
+        field: 'args',
+        label: t('flink.app.programArgs'),
+        component: 'InputTextArea',
+        defaultValue: '',
+        slot: 'args',
+        ifShow: ({ values }) => [JobTypeEnum.JAR, 
JobTypeEnum.PYSPARK].includes(values?.jobType),
+      },
+      {
+        field: 'hadoopUser',
+        label: t('flink.app.hadoopUser'),
+        component: 'Input',
+      },
+      {
+        field: 'description',
+        label: t('common.description'),
+        component: 'InputTextArea',
+        componentProps: { rows: 4, placeholder: 
t('flink.app.addAppTips.descriptionPlaceholder') },
+      },
+    ];
+  });
+  onMounted(async () => {
+    //get flinkEnv
+    fetchSparkEnvList().then((res) => {
+      sparkEnvs.value = res;
+    });
+    /* Get team dependencies */
+    fetchTeamResource({}).then((res) => {
+      teamResource.value = res;
+    });
+    fetchVariableAll().then((res) => {
+      suggestions.value = res.map((v) => {
+        return {
+          text: v.variableCode,
+          description: v.description,
+          value: v.variableValue,
+        };
+      });
+    });
+  });
+  return {
+    formSchema,
+    suggestions,
+    sparkEnvs,
+  };
+}
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useFlinkRender.tsx
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useFlinkRender.tsx
new file mode 100644
index 000000000..b25dfede2
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useFlinkRender.tsx
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { RenderCallbackParams } from '/@/components/Form/src/types/form';
+import { Icon, SvgIcon } from '/@/components/Icon';
+
+import Mergely from '../components/Mergely.vue';
+import { Alert, Dropdown, Input, Menu, Select, Switch, Tag } from 
'ant-design-vue';
+import { SettingTwoTone } from '@ant-design/icons-vue';
+import { unref } from 'vue';
+import { decodeByBase64 } from '/@/utils/cipher';
+import { SelectValue } from 'ant-design-vue/lib/select';
+import { useI18n } from '/@/hooks/web/useI18n';
+import { fetchYarnQueueList } from '/@/api/setting/yarnQueue';
+import { ApiSelect } from '/@/components/Form';
+import { ResourceTypeEnum } from '/@/views/resource/upload/upload.data';
+import { handleSparkConfTemplate } from '/@/api/spark/conf';
+
+const { t } = useI18n();
+/* render input dropdown component */
+export const renderInputDropdown = (
+  formModel: Recordable,
+  field: string,
+  componentProps: { placeholder: string; options: Array<string> },
+) => {
+  return (
+    <Input
+      type="text"
+      placeholder={componentProps.placeholder}
+      allowClear
+      value={formModel[field]}
+      onInput={(e) => (formModel[field] = e.target.value)}
+    >
+      {{
+        addonAfter: () => (
+          <Dropdown placement="bottomRight">
+            {{
+              overlay: () => (
+                <div>
+                  <Menu trigger="['click', 'hover']">
+                    {componentProps.options.map((item) => {
+                      return (
+                        <Menu.Item
+                          key={item}
+                          onClick={() => (formModel[field] = item)}
+                          class="pr-60px"
+                        >
+                          <Icon icon="ant-design:plus-circle-outlined" />
+                          {{ item }}
+                        </Menu.Item>
+                      );
+                    })}
+                  </Menu>
+                  <Icon icon="ant-design:history-outlined" />
+                </div>
+              ),
+            }}
+          </Dropdown>
+        ),
+      }}
+    </Input>
+  );
+};
+
+/* render Yarn Queue */
+export const renderYarnQueue = ({ model, field }: RenderCallbackParams) => {
+  return (
+    <div>
+      <ApiSelect
+        name="yarnQueue"
+        
placeholder={t('setting.yarnQueue.placeholder.yarnQueueLabelExpression')}
+        api={fetchYarnQueueList}
+        params={{ page: 1, pageSize: 9999 }}
+        resultField={'records'}
+        labelField={'queueLabel'}
+        valueField={'queueLabel'}
+        showSearch={true}
+        value={model[field]}
+        onChange={(value: string) => (model[field] = value)}
+      />
+      <p class="conf-desc mt-10px">
+        <span class="note-info">
+          <Tag color="#2db7f5" class="tag-note">
+            {t('flink.app.noteInfo.note')}
+          </Tag>
+          {t('setting.yarnQueue.selectionHint')}
+        </span>
+      </p>
+    </div>
+  );
+};
+
+/* render memory option */
+export const renderDynamicProperties = ({ model, field }: 
RenderCallbackParams) => {
+  return (
+    <div>
+      <Input.TextArea
+        rows={8}
+        name="dynamicProperties"
+        placeholder="$key=$value,If there are multiple parameters,you can new 
line enter them (-D <arg>)"
+        value={model[field]}
+        onInput={(e: ChangeEvent) => (model[field] = e?.target?.value)}
+      />
+      <p class="conf-desc mt-10px">
+        <span class="note-info">
+          <Tag color="#2db7f5" class="tag-note">
+            {t('flink.app.noteInfo.note')}
+          </Tag>
+          <a
+            
href="https://ci.apache.org/projects/flink/flink-docs-stable/ops/config.html";
+            target="_blank"
+            class="pl-5px"
+          >
+            Flink {t('flink.app.noteInfo.officialDoc')}
+          </a>
+        </span>
+      </p>
+    </div>
+  );
+};
+
+export const getAlertSvgIcon = (name: string, text: string) => {
+  return (
+    <Alert type="info">
+      {{
+        message: () => (
+          <div>
+            <SvgIcon class="mr-8px" name={name} style={{ color: '#108ee9' }} />
+            <span>{text}</span>
+          </div>
+        ),
+      }}
+    </Alert>
+  );
+};
+/* render application conf */
+export const renderIsSetConfig = (
+  model: Recordable,
+  field: string,
+  registerConfDrawer: Fn,
+  openConfDrawer: Fn,
+) => {
+  /* Open the sqlConf drawer */
+  async function handleSQLConf(checked: boolean) {
+    if (checked) {
+      if (unref(model.configOverride)) {
+        openConfDrawer(true, {
+          configOverride: unref(model.configOverride),
+        });
+      } else {
+        const res = await handleSparkConfTemplate();
+        openConfDrawer(true, {
+          configOverride: decodeByBase64(res),
+        });
+      }
+    } else {
+      openConfDrawer(false);
+      Object.assign(model, {
+        configOverride: null,
+        isSetConfig: false,
+      });
+    }
+  }
+  function handleEditConfClose() {
+    if (!model.configOverride) {
+      model.isSetConfig = false;
+    }
+  }
+  function handleMergeSubmit(data: { configOverride: string; isSetConfig: 
boolean }) {
+    if (data.configOverride == null || 
!data.configOverride.replace(/^\s+|\s+$/gm, '')) {
+      Object.assign(model, {
+        configOverride: null,
+        isSetConfig: false,
+      });
+    } else {
+      Object.assign(model, {
+        configOverride: data.configOverride,
+        isSetConfig: true,
+      });
+    }
+  }
+  function handleConfChange(checked: boolean) {
+    model[field] = checked;
+    if (checked) {
+      handleSQLConf(true);
+    }
+  }
+  return (
+    <div>
+      <Switch
+        checked-children="ON"
+        un-checked-children="OFF"
+        checked={model[field]}
+        onChange={handleConfChange}
+      />
+      {model[field] && (
+        <SettingTwoTone
+          class="ml-10px"
+          theme="twoTone"
+          two-tone-color="#4a9ff5"
+          onClick={() => handleSQLConf(true)}
+        />
+      )}
+
+      <Mergely
+        onOk={handleMergeSubmit}
+        onClose={() => handleEditConfClose()}
+        onRegister={registerConfDrawer}
+      />
+    </div>
+  );
+};
+
+export const renderResourceFrom = (model: Recordable) => {
+  return (
+    <Select
+      onChange={(value: string) => (model.resourceFrom = value)}
+      value={model.resourceFrom}
+      placeholder="Please select resource from"
+    >
+      <Select.Option value="1">
+        <SvgIcon name="github" />
+        <span class="pl-10px">Project</span>
+        <span class="gray"> (build from Project)</span>
+      </Select.Option>
+      <Select.Option value="2">
+        <SvgIcon name="upload" />
+        <span class="pl-10px">Upload</span>
+        <span class="gray"> (upload local job)</span>
+      </Select.Option>
+    </Select>
+  );
+};
+
+export const renderStreamParkResource = ({ model, resources }) => {
+  const renderOptions = () => {
+    return (resources || [])
+      .filter((item) => item.resourceType !== ResourceTypeEnum.FLINK_APP)
+      .map((resource) => {
+        return (
+          <Select.Option
+            key={resource.id}
+            label={resource.resourceType + '-' + resource.resourceName}
+          >
+            <div>
+              <Tag color="green" class="ml5px" size="small">
+                {resource.resourceType}
+              </Tag>
+              <span class="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.teamResource = value)}
+        value={model.teamResource}
+        placeholder={t('flink.app.resourcePlaceHolder')}
+      >
+        {renderOptions()}
+      </Select>
+    </div>
+  );
+};
+
+export const renderStreamParkJarApp = ({ model, resources }) => {
+  function handleAppChange(value: SelectValue) {
+    const res = resources.filter((item) => item.id == value)[0];
+    model.mainClass = res.mainClass;
+    model.uploadJobJar = res.resourceName;
+  }
+
+  const renderOptions = () => {
+    console.log('resources', resources);
+    return (resources || [])
+      .filter((item) => item.resourceType == ResourceTypeEnum.FLINK_APP)
+      .map((resource) => {
+        return (
+          <Select.Option key={resource.id} label={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"
+        onChange={handleAppChange}
+        value={model.uploadJobJar}
+        placeholder={t('flink.app.selectAppPlaceHolder')}
+      >
+        {renderOptions()}
+      </Select>
+    </div>
+  );
+};
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkAction.tsx
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkAction.tsx
new file mode 100644
index 000000000..6d7beb588
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkAction.tsx
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Alert, Form, Input } from 'ant-design-vue';
+import { h, onMounted, reactive, ref, unref } from 'vue';
+import { fetchAppOwners } from '/@/api/system/user';
+import { SvgIcon } from '/@/components/Icon';
+import { AppExistsStateEnum } from '/@/enums/sparkEnum';
+import { useI18n } from '/@/hooks/web/useI18n';
+import { useMessage } from '/@/hooks/web/useMessage';
+import { fetchCheckSparkName, fetchCopySparkApp, fetchSparkMapping } from 
'/@/api/spark/app';
+
+export const useSparkAction = () => {
+  const { t } = useI18n();
+  const { Swal, createConfirm, createMessage } = useMessage();
+  const users = ref<Recordable>([]);
+
+  /* copy application */
+  function handleCopy(item: Recordable) {
+    const validateStatus = ref<'' | 'error' | 'validating' | 'success' | 
'warning'>('');
+    let help = '';
+    let copyAppName: string | undefined = '';
+    createConfirm({
+      width: '600px',
+      title: () => [
+        h(SvgIcon, {
+          name: 'copy',
+          style: { color: 'red', display: 'inline-block', marginRight: '10px' 
},
+        }),
+        'Copy Application',
+      ],
+      content: () => {
+        return (
+          <Form class="!pt-50px">
+            <Form.Item
+              label="Application Name"
+              labelCol={{ lg: { span: 7 }, sm: { span: 7 } }}
+              wrapperCol={{ lg: { span: 16 }, sm: { span: 4 } }}
+              validateStatus={unref(validateStatus)}
+              help={help}
+              rules={[{ required: true }]}
+            >
+              <Input
+                type="text"
+                placeholder="New Application Name"
+                onInput={(e) => {
+                  copyAppName = e.target.value;
+                }}
+              ></Input>
+            </Form.Item>
+          </Form>
+        );
+      },
+      okText: t('common.apply'),
+      cancelText: t('common.closeText'),
+      onOk: async () => {
+        //1) check empty
+        if (copyAppName == null) {
+          validateStatus.value = 'error';
+          help = 'Sorry, Application Name cannot be empty';
+          return Promise.reject('copy application error');
+        }
+        //2) check name
+        const params = { jobName: copyAppName };
+        const resp = await fetchCheckSparkName(params);
+        const code = parseInt(resp);
+        if (code === AppExistsStateEnum.NO) {
+          try {
+            const { data } = await fetchCopySparkApp({
+              id: item.id,
+              jobName: copyAppName,
+            });
+            const status = data.status || 'error';
+            if (status === 'success') {
+              Swal.fire({
+                icon: 'success',
+                title: 'copy successful',
+                timer: 1500,
+              });
+            }
+          } catch (error: any) {
+            if (error?.response?.data?.message) {
+              createMessage.error(
+                error.response.data.message
+                  .replaceAll(/\[StreamPark\]/g, '')
+                  .replaceAll(/\[StreamPark\]/g, '') || 'copy failed',
+              );
+            }
+            return Promise.reject('copy application error');
+          }
+        } else {
+          validateStatus.value = 'error';
+          if (code === AppExistsStateEnum.IN_DB) {
+            help = t('flink.app.addAppTips.appNameNotUniqueMessage');
+          } else if (code === AppExistsStateEnum.IN_YARN) {
+            help = t('flink.app.addAppTips.appNameExistsInYarnMessage');
+          } else if (code === AppExistsStateEnum.IN_KUBERNETES) {
+            help = t('flink.app.addAppTips.appNameExistsInK8sMessage');
+          } else {
+            help = t('flink.app.addAppTips.appNameNotValid');
+          }
+          return Promise.reject('copy application error');
+        }
+      },
+    });
+  }
+
+  /* mapping */
+  function handleMapping(app: Recordable) {
+    const mappingRef = ref();
+    const formValue = reactive<any>({});
+    createConfirm({
+      width: '600px',
+      title: () => [
+        h(SvgIcon, {
+          name: 'mapping',
+          style: { color: 'green', display: 'inline-block', marginRight: 
'10px' },
+        }),
+        'Mapping Application',
+      ],
+      content: () => {
+        return (
+          <Form
+            class="!pt-40px"
+            ref={mappingRef}
+            name="mappingForm"
+            labelCol={{ lg: { span: 7 }, sm: { span: 7 } }}
+            wrapperCol={{ lg: { span: 16 }, sm: { span: 4 } }}
+            v-model:model={formValue}
+          >
+            <Form.Item label="Application Name">
+              <Alert message={app.jobName} type="info" />
+            </Form.Item>
+            <Form.Item
+              label="JobId"
+              name="jobId"
+              rules={[{ required: true, message: 'jobId is required' }]}
+            >
+              <Input type="text" placeholder="JobId" 
v-model:value={formValue.jobId} />
+            </Form.Item>
+          </Form>
+        );
+      },
+      okText: t('common.apply'),
+      cancelText: t('common.closeText'),
+      onOk: async () => {
+        try {
+          await mappingRef.value.validate();
+          await fetchSparkMapping({
+            id: app.id,
+            appId: formValue.appId,
+            jobId: formValue.jobId,
+          });
+          Swal.fire({
+            icon: 'success',
+            title: 'The current job is mapping',
+            showConfirmButton: false,
+            timer: 2000,
+          });
+          return Promise.resolve();
+        } catch (error) {
+          return Promise.reject(error);
+        }
+      },
+    });
+  }
+
+  onMounted(() => {
+    fetchAppOwners({}).then((res) => {
+      users.value = res;
+    });
+  });
+
+  return {
+    handleCopy,
+    handleMapping,
+    users,
+  };
+};
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkColumns.ts
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkColumns.ts
new file mode 100644
index 000000000..da31b3280
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkColumns.ts
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ColumnType } from 'ant-design-vue/lib/table';
+import { useI18n } from '/@/hooks/web/useI18n';
+import { computed, ref, unref } from 'vue';
+import { BasicColumn } from '/@/components/Table';
+import { AppStateEnum } from '/@/enums/flinkEnum';
+import { dateToDuration } from '/@/utils/dateUtil';
+const { t } = useI18n();
+
+export const useSparkColumns = () => {
+  // app table column width
+  const tableColumnWidth = ref({
+    jobName: 250,
+    flinkVersion: 110,
+    tags: 150,
+    state: 120,
+    release: 190,
+    duration: 150,
+    modifyTime: 165,
+    nickName: 100,
+  });
+
+  function onTableColumnResize(width: number, columns: ColumnType) {
+    if (!columns?.dataIndex) return;
+    const dataIndexStr = columns?.dataIndex.toString() ?? '';
+    if (Reflect.has(tableColumnWidth.value, dataIndexStr)) {
+      // when table column width changed, save it to table column width ref
+      tableColumnWidth.value[dataIndexStr] = width < 100 ? 100 : width;
+    }
+  }
+
+  const getAppColumns = computed((): BasicColumn[] => [
+    {
+      title: t('flink.app.appName'),
+      dataIndex: 'jobName',
+      align: 'left',
+      fixed: 'left',
+      resizable: true,
+      width: unref(tableColumnWidth).jobName,
+    },
+    { title: t('flink.app.flinkVersion'), dataIndex: 'flinkVersion' },
+    { title: t('flink.app.tags'), ellipsis: true, dataIndex: 'tags', width: 
150 },
+    {
+      title: t('flink.app.runStatus'),
+      dataIndex: 'state',
+      fixed: 'right',
+      width: unref(tableColumnWidth).state,
+      filters: [
+        { text: t('flink.app.runStatusOptions.added'), value: 
String(AppStateEnum.ADDED) },
+        { text: t('flink.app.runStatusOptions.starting'), value: 
String(AppStateEnum.STARTING) },
+        { text: t('flink.app.runStatusOptions.running'), value: 
String(AppStateEnum.RUNNING) },
+        { text: t('flink.app.runStatusOptions.failed'), value: 
String(AppStateEnum.FAILED) },
+        { text: t('flink.app.runStatusOptions.canceled'), value: 
String(AppStateEnum.CANCELED) },
+        { text: t('flink.app.runStatusOptions.finished'), value: 
String(AppStateEnum.FINISHED) },
+        { text: t('flink.app.runStatusOptions.suspended'), value: 
String(AppStateEnum.SUSPENDED) },
+        { text: t('flink.app.runStatusOptions.lost'), value: 
String(AppStateEnum.LOST) },
+        { text: t('flink.app.runStatusOptions.silent'), value: 
String(AppStateEnum.SILENT) },
+        {
+          text: t('flink.app.runStatusOptions.terminated'),
+          value: String(AppStateEnum.TERMINATED),
+        },
+      ],
+    },
+    {
+      title: t('flink.app.releaseBuild'),
+      dataIndex: 'release',
+      width: unref(tableColumnWidth).release,
+      fixed: 'right',
+    },
+    {
+      title: t('flink.app.duration'),
+      dataIndex: 'duration',
+      sorter: true,
+      width: unref(tableColumnWidth).duration,
+      customRender: ({ value }) => dateToDuration(value),
+    },
+    {
+      title: t('flink.app.modifiedTime'),
+      dataIndex: 'modifyTime',
+      sorter: true,
+      width: unref(tableColumnWidth).modifyTime,
+    },
+    { title: t('flink.app.owner'), dataIndex: 'nickName', width: 
unref(tableColumnWidth).nickName },
+  ]);
+  return { getAppColumns, onTableColumnResize, tableColumnWidth };
+};
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkTableAction.ts
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkTableAction.ts
new file mode 100644
index 000000000..f5488ffff
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/hooks/useSparkTableAction.ts
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { computed, onMounted, ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { ActionItem, FormProps } from '/@/components/Table';
+import { useMessage } from '/@/hooks/web/useMessage';
+import { AppStateEnum } from '/@/enums/flinkEnum';
+import { JobTypeEnum } from '/@/enums/sparkEnum';
+import { usePermission } from '/@/hooks/web/usePermission';
+import { useI18n } from '/@/hooks/web/useI18n';
+import { isFunction, isObject } from '/@/utils/is';
+import { fetchSparkAppRecord, fetchSparkAppRemove } from '/@/api/spark/app';
+import type { SparkApplication } from '/@/api/spark/app.type';
+import { useSparkAction } from './useSparkAction';
+
+// Create form configurations and operation functions in the application table
+export const useSparkTableAction = (handlePageDataReload: Fn) => {
+  const { t } = useI18n();
+  const tagsOptions = ref<Recordable>([]);
+
+  const router = useRouter();
+  const { createMessage } = useMessage();
+  const { hasPermission } = usePermission();
+  const { handleCopy, handleMapping, users } = useSparkAction();
+
+  /* Operation button list */
+  function getActionList(record: SparkApplication, _currentPageNo: number): 
ActionItem[] {
+    return [
+      {
+        label: t('flink.app.operation.copy'),
+        auth: 'app:copy',
+        icon: 'ant-design:copy-outlined',
+        onClick: handleCopy.bind(null, record),
+      },
+      {
+        label: t('flink.app.operation.remapping'),
+        ifShow: [
+          AppStateEnum.ADDED,
+          AppStateEnum.FAILED,
+          AppStateEnum.CANCELED,
+          AppStateEnum.KILLED,
+          AppStateEnum.SUCCEEDED,
+          AppStateEnum.TERMINATED,
+          AppStateEnum.POS_TERMINATED,
+          AppStateEnum.FINISHED,
+          AppStateEnum.SUSPENDED,
+          AppStateEnum.LOST,
+        ].includes(record.state as AppStateEnum),
+        auth: 'app:mapping',
+        icon: 'ant-design:deployment-unit-outlined',
+        onClick: handleMapping.bind(null, record),
+      },
+      {
+        popConfirm: {
+          title: t('flink.app.operation.deleteTip'),
+          confirm: handleDelete.bind(null, record),
+        },
+        label: t('common.delText'),
+        ifShow: [
+          AppStateEnum.ADDED,
+          AppStateEnum.FAILED,
+          AppStateEnum.CANCELED,
+          AppStateEnum.FINISHED,
+          AppStateEnum.LOST,
+          AppStateEnum.TERMINATED,
+          AppStateEnum.POS_TERMINATED,
+          AppStateEnum.SUCCEEDED,
+          AppStateEnum.KILLED,
+        ].includes(record.state as AppStateEnum),
+        auth: 'app:delete',
+        icon: 'ant-design:delete-outlined',
+        color: 'error',
+      },
+    ];
+  }
+  /** action button is show */
+  function actionIsShow(tableActionItem: ActionItem): boolean | undefined {
+    const { auth, ifShow } = tableActionItem;
+    let flag = isFunction(ifShow) ? ifShow(tableActionItem) : ifShow;
+    /** Judgment auth when not set or allowed to display */
+    if ((flag || flag === undefined) && auth) flag = hasPermission(auth);
+    return flag;
+  }
+
+  function getTableActions(
+    record: SparkApplication,
+    currentPageNo: any,
+  ): { actions: ActionItem[]; dropDownActions: ActionItem[] } {
+    const tableAction = getActionList(record, currentPageNo).filter((item: 
ActionItem) =>
+      actionIsShow(item),
+    );
+    const actions = tableAction.splice(0, 3).map((item: ActionItem) => {
+      if (item.label) {
+        item.tooltip = {
+          title: item.label,
+        };
+        delete item.label;
+      }
+      return item;
+    });
+    return {
+      actions,
+      dropDownActions: tableAction.map((item: ActionItem) => {
+        if (!item.label && isObject(item.tooltip)) item.label = 
item.tooltip?.title || '';
+        return item;
+      }),
+    };
+  }
+
+  /* Click to delete */
+  async function handleDelete(app: SparkApplication) {
+    const hide = createMessage.loading('deleting', 0);
+    try {
+      await fetchSparkAppRemove(app.id!);
+      createMessage.success('delete successful');
+      handlePageDataReload(false);
+    } catch (error) {
+      console.error(error);
+    } finally {
+      hide();
+    }
+  }
+
+  const formConfig = computed((): Partial<FormProps> => {
+    const tableFormConfig: FormProps = {
+      baseColProps: { span: 5, style: { paddingRight: '20px' } },
+      actionColOptions: { span: 4 },
+      showSubmitButton: false,
+      showResetButton: false,
+      async resetFunc() {
+        router.push({ path: '/spark/app/create' });
+      },
+      schemas: [
+        {
+          label: t('flink.app.tags'),
+          field: 'tags',
+          component: 'Select',
+          componentProps: {
+            placeholder: t('flink.app.tags'),
+            showSearch: true,
+            options: tagsOptions.value.map((t: Recordable) => ({ label: t, 
value: t })),
+            onChange: handlePageDataReload.bind(null, false),
+          },
+        },
+        {
+          label: t('flink.app.owner'),
+          field: 'userId',
+          component: 'Select',
+          componentProps: {
+            placeholder: t('flink.app.owner'),
+            showSearch: true,
+            options: users.value.map((u: Recordable) => {
+              return { label: u.nickName || u.username, value: u.userId };
+            }),
+            onChange: handlePageDataReload.bind(null, false),
+          },
+        },
+        {
+          label: t('flink.app.jobType'),
+          field: 'jobType',
+          component: 'Select',
+          componentProps: {
+            placeholder: t('flink.app.jobType'),
+            showSearch: true,
+            options: [
+              { label: 'JAR', value: JobTypeEnum.JAR },
+              { label: 'SQL', value: JobTypeEnum.SQL },
+              { label: 'PySpark', value: JobTypeEnum.PYSPARK },
+            ],
+            onChange: handlePageDataReload.bind(null, false),
+          },
+        },
+        {
+          label: t('flink.app.searchName'),
+          field: 'jobName',
+          component: 'Input',
+          componentProps: {
+            placeholder: t('flink.app.searchName'),
+            onChange: handlePageDataReload.bind(null, false),
+            onSearch: handlePageDataReload.bind(null, false),
+          },
+        },
+      ],
+    };
+    if (hasPermission('app:create')) {
+      Object.assign(tableFormConfig, {
+        showResetButton: true,
+        resetButtonOptions: {
+          text: t('common.add'),
+          color: 'primary',
+          preIcon: 'ant-design:plus-outlined',
+        },
+      });
+    }
+    return tableFormConfig;
+  });
+
+  /*  tag */
+  function handleInitTagsOptions() {
+    const params = Object.assign({}, { pageSize: 999999999, pageNum: 1 });
+    fetchSparkAppRecord(params).then((res) => {
+      const dataSource = res?.records || [];
+      dataSource.forEach((record) => {
+        if (record.tags !== null && record.tags !== undefined && record.tags 
!== '') {
+          const tagsArray = record.tags.split(',') as string[];
+          tagsArray.forEach((x: string) => {
+            if (x.length > 0 && tagsOptions.value.indexOf(x) == -1) {
+              tagsOptions.value.push(x);
+            }
+          });
+        }
+      });
+    });
+  }
+  onMounted(() => {
+    handleInitTagsOptions();
+  });
+  return { getTableActions, formConfig };
+};
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/index.vue 
b/streampark-console/streampark-console-webapp/src/views/spark/app/index.vue
new file mode 100644
index 000000000..c660c040a
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/views/spark/app/index.vue
@@ -0,0 +1,317 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script lang="ts" setup>
+  import { nextTick, ref, onUnmounted, onMounted } from 'vue';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { AppStateEnum, OptionStateEnum, ReleaseStateEnum } from 
'/@/enums/flinkEnum';
+  import { JobTypeEnum } from '/@/enums/sparkEnum';
+  import { useTimeoutFn } from '@vueuse/core';
+  import { Tooltip, Badge, Tag, Popover } from 'ant-design-vue';
+  import { useTable } from '/@/components/Table';
+  import { PageWrapper } from '/@/components/Page';
+  import { BasicTable, TableAction } from '/@/components/Table';
+  import { releaseTitleMap } from './data';
+
+  import AppDashboard from './components/AppDashboard.vue';
+  import State, {
+    buildStatusMap,
+    optionStateMap,
+    releaseStateMap,
+    stateMap,
+  } from '/@/views/flink/app/components/State';
+  import { useSparkColumns } from './hooks/useSparkColumns';
+  import AppTableResize from '/@/views/flink/app/components/AppResize.vue';
+  import { fetchSparkAppRecord } from '/@/api/spark/app';
+  import { useSparkTableAction } from './hooks/useSparkTableAction';
+  defineOptions({
+    name: 'SparkApplication',
+  });
+  const { t } = useI18n();
+  const optionApps = {
+    starting: new Map(),
+    stopping: new Map(),
+    release: new Map(),
+    savepointing: new Map(),
+  };
+  const errorCount = ref(0);
+  const appDashboardRef = ref<any>();
+
+  const currentTablePage = ref(1);
+  const { onTableColumnResize, tableColumnWidth, getAppColumns } = 
useSparkColumns();
+  const titleLenRef = ref({
+    maxState: '',
+    maxRelease: '',
+    maxBuild: '',
+  });
+
+  const getSparkAppList = async (params: Recordable) => {
+    try {
+      if (Reflect.has(params, 'state')) {
+        if (params.state && params.state.length > 0) {
+          params['stateArray'] = [...params.state];
+        }
+        delete params.state;
+      }
+      currentTablePage.value = params.pageNum;
+      // sessionStorage.setItem('appPageNo', params.pageNum);
+      const res = await fetchSparkAppRecord(params);
+
+      const timestamp = new Date().getTime();
+      res.records.forEach((x) => {
+        Object.assign(x, {
+          expanded: [
+            {
+              appId: x.appId,
+              jmMemory: x.jmMemory,
+              tmMemory: x.tmMemory,
+              totalTM: x.totalTM,
+              totalSlot: x.totalSlot,
+              availableSlot: x.availableSlot,
+            },
+          ],
+        });
+        if (x['optionState'] === OptionStateEnum.NONE) {
+          if (optionApps.starting.get(x.id)) {
+            if (timestamp - optionApps.starting.get(x.id) > 2000 * 2) {
+              optionApps.starting.delete(x.id);
+            }
+          }
+          if (optionApps.stopping.get(x.id)) {
+            if (timestamp - optionApps.stopping.get(x.id) > 2000) {
+              optionApps.stopping.delete(x.id);
+            }
+          }
+          if (optionApps.release.get(x.id)) {
+            if (timestamp - optionApps.release.get(x.id) > 2000) {
+              optionApps.release.delete(x.id);
+            }
+          }
+          if (optionApps.savepointing.get(x.id)) {
+            if (timestamp - optionApps.savepointing.get(x.id) > 2000) {
+              optionApps.savepointing.delete(x.id);
+            }
+          }
+        }
+      });
+      const stateLenMap = res.records.reduce(
+        (
+          prev: {
+            maxState: string;
+            maxRelease: string;
+            maxBuild: string;
+          },
+          cur: any,
+        ) => {
+          const { state, optionState, release, buildStatus } = cur;
+          // state title len
+          if (optionState === OptionStateEnum.NONE) {
+            const stateStr = stateMap[state]?.title;
+            if (stateStr && stateStr.length > prev.maxState.length) {
+              prev.maxState = stateStr;
+            }
+          } else {
+            const stateStr = optionStateMap[optionState]?.title;
+            if (stateStr && stateStr.length > prev.maxState.length) {
+              prev.maxState = stateStr;
+            }
+          }
+
+          //release title len
+          const releaseStr = releaseStateMap[release]?.title;
+          if (releaseStr && releaseStr.length > prev.maxRelease.length) {
+            prev.maxRelease = releaseStr;
+          }
+
+          //build title len
+          const buildStr = buildStatusMap[buildStatus]?.title;
+          if (buildStr && buildStr.length > prev.maxBuild.length) {
+            prev.maxBuild = buildStr;
+          }
+          return prev;
+        },
+        {
+          maxState: '',
+          maxRelease: '',
+          maxBuild: '',
+        },
+      );
+      Object.assign(titleLenRef.value, stateLenMap);
+
+      return {
+        list: res.records,
+        total: res.total,
+      };
+    } catch (error) {
+      errorCount.value += 1;
+      console.error(error);
+    }
+  };
+  const [registerTable, { reload, getLoading, setPagination }] = useTable({
+    rowKey: 'id',
+    api: getSparkAppList,
+    immediate: true,
+    canResize: false,
+    showIndexColumn: false,
+    showTableSetting: true,
+    useSearchForm: true,
+    tableSetting: { fullScreen: true, redo: false },
+    actionColumn: {
+      dataIndex: 'operation',
+      title: t('component.table.operation'),
+      width: 180,
+    },
+  });
+
+  const { getTableActions, formConfig } = 
useSparkTableAction(handlePageDataReload);
+
+  function handlePageDataReload(polling = false) {
+    nextTick(() => {
+      appDashboardRef.value?.handleDashboard(false);
+      reload({ polling });
+    });
+  }
+  const { start, stop } = useTimeoutFn(() => {
+    if (errorCount.value <= 3) {
+      start();
+    } else {
+      return;
+    }
+    if (!getLoading()) {
+      handlePageDataReload(true);
+    }
+  }, 2000);
+
+  onMounted(() => {
+    // If there is a page, jump to the page number of the record
+    const currentPage = sessionStorage.getItem('appPageNo');
+    if (currentPage) {
+      setPagination({
+        current: Number(currentPage) || 1,
+      });
+      sessionStorage.removeItem('appPageNo');
+    }
+  });
+
+  onUnmounted(() => {
+    stop();
+  });
+</script>
+<template>
+  <PageWrapper contentFullHeight>
+    <AppDashboard ref="appDashboardRef" />
+    <BasicTable
+      @register="registerTable"
+      :columns="getAppColumns"
+      @resize-column="onTableColumnResize"
+      class="app_list !px-0 pt-20px"
+      :formConfig="formConfig"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.dataIndex === 'jobName'">
+          <span class="app_type app_jar" v-if="record['jobType'] == 
JobTypeEnum.JAR"> JAR </span>
+          <span class="app_type app_sql" v-if="record['jobType'] == 
JobTypeEnum.SQL"> SQL </span>
+          <span class="app_type app_py" v-if="record['jobType'] == 
JobTypeEnum.PYSPARK">
+            PySpark
+          </span>
+          <span
+            class="link"
+            :class="{
+              'cursor-pointer':
+                [AppStateEnum.RESTARTING, 
AppStateEnum.RUNNING].includes(record.state) ||
+                record['optionState'] === OptionStateEnum.SAVEPOINTING,
+            }"
+          >
+            <Popover :title="t('common.detailText')">
+              <template #content>
+                <div class="flex">
+                  <span class="pr-6px font-bold">{{ t('flink.app.appName') 
}}:</span>
+                  <div class="max-w-300px break-words">{{ record.jobName 
}}</div>
+                </div>
+                <div class="pt-2px">
+                  <span class="pr-6px font-bold">{{ t('flink.app.jobType') 
}}:</span>
+                  <Tag color="blue">
+                    <span v-if="record['jobType'] == JobTypeEnum.JAR"> JAR 
</span>
+                    <span v-if="record['jobType'] == JobTypeEnum.SQL"> SQL 
</span>
+                    <span v-if="record['jobType'] == JobTypeEnum.PYSPARK"> 
PySpark </span>
+                  </Tag>
+                </div>
+                <div class="pt-2px flex">
+                  <span class="pr-6px font-bold">{{ t('common.description') 
}}:</span>
+                  <div class="max-w-300px break-words">{{ record.description 
}}</div>
+                </div>
+              </template>
+              {{ record.jobName }}
+            </Popover>
+          </span>
+
+          <template v-if="record['jobType'] == JobTypeEnum.JAR">
+            <Badge
+              v-if="record.release === ReleaseStateEnum.NEED_CHECK"
+              class="build-badge"
+              count="NEW"
+              :title="t('flink.app.view.recheck')"
+            />
+            <Badge
+              v-else-if="record.release >= ReleaseStateEnum.RELEASING"
+              class="build-badge"
+              count="NEW"
+              :title="t('flink.app.view.changed')"
+            />
+          </template>
+        </template>
+        <template v-if="column.dataIndex === 'tags'">
+          <Tooltip v-if="record.tags" :title="record.tags">
+            <span
+              v-for="(tag, index) in record.tags.split(',')"
+              :key="'tag-'.concat(index.toString())"
+              class="pl-4px"
+            >
+              <Tag color="blue">{{ tag }}</Tag>
+            </span>
+          </Tooltip>
+        </template>
+        <template v-if="column.dataIndex === 'task'">
+          <State option="task" :data="record" />
+        </template>
+        <template v-if="column.dataIndex === 'state'">
+          <State option="state" :data="record" 
:maxTitle="titleLenRef.maxState" />
+        </template>
+        <template v-if="column.dataIndex === 'release'">
+          <State
+            option="release"
+            :maxTitle="titleLenRef.maxRelease"
+            :title="releaseTitleMap[record.release] || ''"
+            :data="record"
+          />
+        </template>
+        <template v-if="column.dataIndex === 'operation'">
+          <TableAction v-bind="getTableActions(record, currentTablePage)" />
+        </template>
+      </template>
+      <template #insertTable="{ tableContainer }">
+        <AppTableResize
+          :table-container="tableContainer"
+          :resize-min="100"
+          v-model:left="tableColumnWidth.jobName"
+        />
+      </template>
+    </BasicTable>
+  </PageWrapper>
+</template>
+<style lang="less">
+  @import url('./styles/View.less');
+</style>
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/sqlFormatter.js
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/sqlFormatter.js
new file mode 100644
index 000000000..9c00b6f6a
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/sqlFormatter.js
@@ -0,0 +1,408 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Formatter from 'sql-formatter/lib/core/Formatter';
+import Tokenizer from 'sql-formatter/lib/core/Tokenizer';
+
+// 
https://jakewheat.github.io/sql-overview/sql-2008-foundation-grammar.html#reserved-word
+const reservedWords = [
+  'ABS',
+  'ALL',
+  'ALLOCATE',
+  'ALTER',
+  'AND',
+  'ANY',
+  'ARE',
+  'ARRAY',
+  'AS',
+  'ASENSITIVE',
+  'ASYMMETRIC',
+  'AT',
+  'ATOMIC',
+  'AUTHORIZATION',
+  'AVG',
+  'BEGIN',
+  'BETWEEN',
+  'BIGINT',
+  'BINARY',
+  'BLOB',
+  'BOOLEAN',
+  'BOTH',
+  'BY',
+  'CALL',
+  'CALLED',
+  'CARDINALITY',
+  'CASCADED',
+  'CASE',
+  'CAST',
+  'CEIL',
+  'CEILING',
+  'CHAR',
+  'CHAR_LENGTH',
+  'CHARACTER',
+  'CHARACTER_LENGTH',
+  'CHECK',
+  'CLOB',
+  'CLOSE',
+  'COALESCE',
+  'COLLATE',
+  'COLLECT',
+  'COLUMN',
+  'COMMIT',
+  'CONDITION',
+  'CONNECT',
+  'CONSTRAINT',
+  'CONVERT',
+  'CORR',
+  'CORRESPONDING',
+  'COUNT',
+  'COVAR_POP',
+  'COVAR_SAMP',
+  'CREATE',
+  'CROSS',
+  'CUBE',
+  'CUME_DIST',
+  'CURRENT',
+  'CURRENT_CATALOG',
+  'CURRENT_DATE',
+  'CURRENT_DEFAULT_TRANSFORM_GROUP',
+  'CURRENT_PATH',
+  'CURRENT_ROLE',
+  'CURRENT_SCHEMA',
+  'CURRENT_TIME',
+  'CURRENT_TIMESTAMP',
+  'CURRENT_TRANSFORM_GROUP_FOR_TYPE',
+  'CURRENT_USER',
+  'CURSOR',
+  'CYCLE',
+  'DATE',
+  'DAY',
+  'DEALLOCATE',
+  'DEC',
+  'DECIMAL',
+  'DECLARE',
+  'DEFAULT',
+  'DELETE',
+  'DENSE_RANK',
+  'DEREF',
+  'DESCRIBE',
+  'DETERMINISTIC',
+  'DISCONNECT',
+  'DISTINCT',
+  'DOUBLE',
+  'DROP',
+  'DYNAMIC',
+  'EACH',
+  'ELEMENT',
+  'ELSE',
+  'END',
+  'END-EXEC',
+  'ESCAPE',
+  'EVERY',
+  'EXCEPT',
+  'EXEC',
+  'EXECUTE',
+  'EXISTS',
+  'EXP',
+  'EXTERNAL',
+  'EXTRACT',
+  'FALSE',
+  'FETCH',
+  'FILTER',
+  'FLOAT',
+  'FLOOR',
+  'FOR',
+  'FOREIGN',
+  'FREE',
+  'FROM',
+  'FULL',
+  'FUNCTION',
+  'FUSION',
+  'GET',
+  'GLOBAL',
+  'GRANT',
+  'GROUP',
+  'GROUPING',
+  'HAVING',
+  'HOLD',
+  'HOUR',
+  'IDENTITY',
+  'IN',
+  'INDICATOR',
+  'INNER',
+  'INOUT',
+  'INSENSITIVE',
+  'INSERT',
+  'INT',
+  'INTEGER',
+  'INTERSECT',
+  'INTERSECTION',
+  'INTERVAL',
+  'INTO',
+  'IS',
+  'JOIN',
+  'LANGUAGE',
+  'LARGE',
+  'LATERAL',
+  'LEADING',
+  'LEFT',
+  'LIKE',
+  'LIKE_REGEX',
+  'LN',
+  'LOCAL',
+  'LOCALTIME',
+  'LOCALTIMESTAMP',
+  'LOWER',
+  'MATCH',
+  'MAX',
+  'MEMBER',
+  'MERGE',
+  'METHOD',
+  'MIN',
+  'MINUTE',
+  'MOD',
+  'MODIFIES',
+  'MODULE',
+  'MONTH',
+  'MULTISET',
+  'NATIONAL',
+  'NATURAL',
+  'NCHAR',
+  'NCLOB',
+  'NEW',
+  'NO',
+  'NONE',
+  'NORMALIZE',
+  'NOT',
+  'NULL',
+  'NULLIF',
+  'NUMERIC',
+  'OCTET_LENGTH',
+  'OCCURRENCES_REGEX',
+  'OF',
+  'OLD',
+  'ON',
+  'ONLY',
+  'OPEN',
+  'OR',
+  'ORDER',
+  'OUT',
+  'OUTER',
+  'OVER',
+  'OVERLAPS',
+  'OVERLAY',
+  'PARAMETER',
+  'PARTITION',
+  'PERCENT_RANK',
+  'PERCENTILE_CONT',
+  'PERCENTILE_DISC',
+  'POSITION',
+  'POSITION_REGEX',
+  'POWER',
+  'PRECISION',
+  'PREPARE',
+  'PRIMARY',
+  'PROCEDURE',
+  'RANGE',
+  'RANK',
+  'READS',
+  'REAL',
+  'RECURSIVE',
+  'REF',
+  'REFERENCES',
+  'REFERENCING',
+  'REGR_AVGX',
+  'REGR_AVGY',
+  'REGR_COUNT',
+  'REGR_INTERCEPT',
+  'REGR_R2',
+  'REGR_SLOPE',
+  'REGR_SXX',
+  'REGR_SXY',
+  'REGR_SYY',
+  'RELEASE',
+  'RESULT',
+  'RETURN',
+  'RETURNS',
+  'REVOKE',
+  'RIGHT',
+  'ROLLBACK',
+  'ROLLUP',
+  'ROW',
+  'ROW_NUMBER',
+  'ROWS',
+  'SAVEPOINT',
+  'SCOPE',
+  'SCROLL',
+  'SEARCH',
+  'SECOND',
+  'SELECT',
+  'SENSITIVE',
+  'SESSION_USER',
+  'SET',
+  'SIMILAR',
+  'SMALLINT',
+  'SOME',
+  'SPECIFIC',
+  'SPECIFICTYPE',
+  'SQL',
+  'SQLEXCEPTION',
+  'SQLSTATE',
+  'SQLWARNING',
+  'SQRT',
+  'START',
+  'STATIC',
+  'STDDEV_POP',
+  'STDDEV_SAMP',
+  'SUBMULTISET',
+  'SUBSTRING',
+  'SUBSTRING_REGEX',
+  'SUM',
+  'SYMMETRIC',
+  'SYSTEM',
+  'SYSTEM_USER',
+  'TABLE',
+  'TABLESAMPLE',
+  'THEN',
+  'TIME',
+  'TIMESTAMP',
+  'TIMEZONE_HOUR',
+  'TIMEZONE_MINUTE',
+  'TO',
+  'TRAILING',
+  'TRANSLATE',
+  'TRANSLATE_REGEX',
+  'TRANSLATION',
+  'TREAT',
+  'TRIGGER',
+  'TRIM',
+  'TRUE',
+  'UESCAPE',
+  'UNION',
+  'UNIQUE',
+  'UNKNOWN',
+  'UNNEST',
+  'UPDATE',
+  'UPPER',
+  'USER',
+  'USING',
+  'VALUE',
+  'VALUES',
+  'VAR_POP',
+  'VAR_SAMP',
+  'VARBINARY',
+  'VARCHAR',
+  'VARYING',
+  'WHEN',
+  'WHENEVER',
+  'WHERE',
+  'WIDTH_BUCKET',
+  'WINDOW',
+  'WITH',
+  'WITHIN',
+  'WITHOUT',
+  'YEAR',
+];
+
+const reservedTopLevelWords = [
+  'ADD',
+  'ALTER COLUMN',
+  'ALTER TABLE',
+  'CASE',
+  'DELETE FROM',
+  'END',
+  'FETCH FIRST',
+  'FETCH NEXT',
+  'FETCH PRIOR',
+  'FETCH LAST',
+  'FETCH ABSOLUTE',
+  'FETCH RELATIVE',
+  'FROM',
+  'GROUP BY',
+  'HAVING',
+  'INSERT INTO',
+  'LIMIT',
+  'ORDER BY',
+  'SELECT',
+  'SET SCHEMA',
+  'SET',
+  'UPDATE',
+  'VALUES',
+  'WHERE',
+];
+
+const reservedTopLevelWordsNoIndent = [
+  'INTERSECT',
+  'INTERSECT ALL',
+  'INTERSECT DISTINCT',
+  'UNION',
+  'UNION ALL',
+  'UNION DISTINCT',
+  'EXCEPT',
+  'EXCEPT ALL',
+  'EXCEPT DISTINCT',
+];
+
+const reservedNewlineWords = [
+  'AND',
+  'ELSE',
+  'OR',
+  'WHEN',
+  // joins
+  'JOIN',
+  'INNER JOIN',
+  'LEFT JOIN',
+  'LEFT OUTER JOIN',
+  'RIGHT JOIN',
+  'RIGHT OUTER JOIN',
+  'FULL JOIN',
+  'FULL OUTER JOIN',
+  'CROSS JOIN',
+  'NATURAL JOIN',
+];
+
+class FlinkSqlFormatter extends Formatter {
+  tokenizer() {
+    return new Tokenizer({
+      reservedWords,
+      reservedTopLevelWords,
+      reservedNewlineWords,
+      reservedTopLevelWordsNoIndent,
+      stringTypes: [`""`, "''", '``'],
+      openParens: ['(', 'CASE'],
+      closeParens: [')', 'END'],
+      indexedPlaceholderTypes: ['?'],
+      namedPlaceholderTypes: [],
+      lineCommentTypes: ['--'],
+    });
+  }
+}
+
+/**
+ * Format whitespace in a query to make it easier to read.
+ *
+ * @param {String} query
+ * @param {Object} cfg
+ *  @param {String} cfg.indent Characters used for indentation, default is "  
" (2 spaces)
+ *  @param {Boolean} cfg.uppercase Converts keywords to uppercase
+ *  @param {Integer} cfg.linesBetweenQueries How many line breaks between 
queries
+ *  @param {Object} cfg.params Collection of params for placeholder replacement
+ * @return {String}
+ */
+export const format = (query, config = {}) => {
+  return new FlinkSqlFormatter(config).format(query);
+};
diff --git 
a/streampark-console/streampark-console-webapp/src/views/spark/app/styles/View.less
 
b/streampark-console/streampark-console-webapp/src/views/spark/app/styles/View.less
new file mode 100644
index 000000000..98852052f
--- /dev/null
+++ 
b/streampark-console/streampark-console-webapp/src/views/spark/app/styles/View.less
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+.link {
+  color: #1890ff;
+  text-decoration: none;
+  background-color: transparent;
+  outline: none;
+  transition: color 0.3s;
+}
+
+.ant-upload.ant-upload-drag p.ant-upload-drag-icon .anticon {
+  font-size: 100px;
+}
+
+.ant-alert.ant-alert-no-icon {
+  padding: 6px 15px;
+}
+
+.ant-input-number {
+  width: 100%;
+}
+
+.ant-statistic {
+  line-height: 1.8;
+}
+
+.gutter-box {
+  padding: 10px 15px;
+  background: @component-background;
+  font-size: 14px;
+  font-variant: tabular-nums;
+  line-height: 1.8;
+  list-style: none;
+  font-feature-settings: 'tnum';
+  position: relative;
+  border-radius: 4px;
+  transition: all 0.3s;
+
+  .ant-divider-horizontal {
+    margin: 10px 0;
+  }
+
+  .dash-statistic {
+    .ant-card-body {
+      padding: 8px !important;
+    }
+  }
+
+  .stat-right {
+    float: right;
+
+    .ant-card-body {
+      float: right;
+    }
+  }
+}
+
+.build-badge {
+  float: right;
+  margin-right: 5px;
+  font-size: 12px;
+  transform: scale(0.7, 0.7);
+
+  .ant-badge-count {
+    padding: 0 5px 1px;
+  }
+}
+
+.app_list {
+  .app_type {
+    display: inline-block;
+    text-align: center;
+    vertical-align: middle;
+    margin-right: 5px;
+    line-height: 12px;
+    overflow: hidden;
+  }
+
+  .app_sql {
+    padding: 2px 4px;
+    border: 1px solid #0070cc;
+    /* stylelint-disable-next-line color-function-notation */
+    background: rgba(0, 112, 204, 20%);
+    /* stylelint-disable-next-line color-function-notation */
+    color: rgba(0, 0, 0, 85%);
+    font-size: 14px;
+    transform: scale(0.7);
+  }
+
+  .app_jar {
+    border: 1px solid #06f;
+    padding: 3px 5px;
+    background: #1890ff;
+    color: #f5f5f5;
+    font-size: 14px;
+    transform: scale(0.7);
+  }
+
+  .expanded-table {
+    .ant-table-tbody {
+      tr {
+        border-bottom: none !important;
+        padding: 11px 9px !important;
+
+        td {
+          border-bottom: none !important;
+          padding: 11px 9px !important;
+        }
+
+        th {
+          font-size: 13px;
+        }
+      }
+    }
+  }
+
+  .expand-icon-open {
+    font-size: 10px;
+  }
+
+  .expand-icon-close {
+    font-size: 10px;
+    color: darkgray;
+  }
+
+  .close-deploy {
+    left: 15px;
+    font-size: 8px;
+    font-weight: bold;
+    top: -8px;
+  }
+}
+
+.def-margin-bottom {
+  margin-bottom: 10px;
+}
+
+.running-tag {
+  animation: running-tag-color 800ms ease-out infinite alternate;
+}
+
+@keyframes running-tag-color {
+  0% {
+    border-color: #3da9f2;
+    box-shadow: 0 0 1px #3da9f2, inset 0 0 2px #3da9f2;
+  }
+
+  100% {
+    border-color: #3da9f2;
+    box-shadow: 0 0 10px #3da9f2, inset 0 0 5px #3da9f2;
+  }
+}
+
+[data-theme='dark'] {
+  .app_list .app_sql {
+    color: #f4f5f6;
+  }
+
+  .gutter-box {
+    box-shadow: 0 1px 6px #000;
+  }
+}

Reply via email to