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

wuzhiguo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/bigtop-manager.git


The following commit(s) were added to refs/heads/main by this push:
     new bd0a06b6 BIGTOP-4400: Add job progress floating window (#200)
bd0a06b6 is described below

commit bd0a06b646bd7a026b49a5513d67638d119c60cd
Author: Fdefined <[email protected]>
AuthorDate: Mon Apr 7 16:17:01 2025 +0800

    BIGTOP-4400: Add job progress floating window (#200)
---
 bigtop-manager-ui/src/api/command/types.ts         |   1 +
 bigtop-manager-ui/src/api/types.ts                 |   2 +-
 .../src/assets/images/svg/installing.svg           |  77 +++---
 .../src/assets/images/svg/processing.svg           |   4 +
 .../src/assets/images/svg/question.svg             |   3 +-
 .../images/svg/{processing.svg => remove.svg}      |   7 +-
 .../images/svg/{processing.svg => retry.svg}       |  12 +-
 .../components/service-configurator.vue            |  67 +++--
 .../src/components/{job => job-modal}/index.vue    | 158 +++++-------
 .../src/components/job/custom-progress.vue         |  35 +--
 bigtop-manager-ui/src/components/job/index.vue     |   2 +-
 .../src/components/log-view/index.vue              |  36 +--
 bigtop-manager-ui/src/locales/en_US/common.ts      |   3 +-
 bigtop-manager-ui/src/locales/en_US/job.ts         |   6 +-
 bigtop-manager-ui/src/locales/zh_CN/common.ts      |   3 +-
 bigtop-manager-ui/src/locales/zh_CN/job.ts         |   6 +-
 .../src/pages/cluster-manage/cluster/create.vue    |   9 +-
 .../src/pages/cluster-manage/cluster/index.vue     |  23 +-
 .../src/pages/cluster-manage/cluster/overview.vue  |  32 ++-
 .../cluster/service-management/components.vue      |  19 +-
 .../cluster/service-management/configs.vue         |  61 +++--
 .../cluster/service-management/index.vue           |  25 +-
 .../src/pages/cluster-manage/cluster/service.vue   |  45 ++--
 .../src/pages/cluster-manage/hosts/detail.vue      |  20 +-
 .../src/pages/cluster-manage/hosts/index.vue       |   9 +-
 .../src/pages/cluster-manage/hosts/overview.vue    |  83 ++++---
 .../pages/cluster-manage/infrastructures/index.vue |  50 ++--
 bigtop-manager-ui/src/store/cluster/index.ts       |   2 +
 bigtop-manager-ui/src/store/installed/index.ts     |  29 ++-
 bigtop-manager-ui/src/store/job-progress/index.ts  | 276 +++++++++++++++++++++
 bigtop-manager-ui/src/store/service/index.ts       |  12 +-
 bigtop-manager-ui/src/store/stack/index.ts         |  43 +++-
 bigtop-manager-ui/src/styles/index.scss            |  69 +++++-
 33 files changed, 868 insertions(+), 361 deletions(-)

diff --git a/bigtop-manager-ui/src/api/command/types.ts 
b/bigtop-manager-ui/src/api/command/types.ts
index 97313362..25550934 100644
--- a/bigtop-manager-ui/src/api/command/types.ts
+++ b/bigtop-manager-ui/src/api/command/types.ts
@@ -135,6 +135,7 @@ export interface AttrsReq {
 
 export interface CommandVO {
   id?: number
+  name?: string
   state?: string
   [property: string]: any
 }
diff --git a/bigtop-manager-ui/src/api/types.ts 
b/bigtop-manager-ui/src/api/types.ts
index 91d65f12..d879febf 100644
--- a/bigtop-manager-ui/src/api/types.ts
+++ b/bigtop-manager-ui/src/api/types.ts
@@ -29,7 +29,7 @@ export interface PageVO<T = any> {
 }
 
 export interface ListParams {
-  orderBy?: string
+  order?: string
   pageNum?: number
   pageSize?: number
   sort?: string
diff --git a/bigtop-manager-ui/src/assets/images/svg/installing.svg 
b/bigtop-manager-ui/src/assets/images/svg/installing.svg
index a51ec205..03688318 100644
--- a/bigtop-manager-ui/src/assets/images/svg/installing.svg
+++ b/bigtop-manager-ui/src/assets/images/svg/installing.svg
@@ -19,40 +19,45 @@
 -->
 <svg xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; width="16" height="16"
   viewBox="0 0 16 16" fill="none">
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M8 1.33337L8 2.66671">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M11.3332 2.22656L10.6665 3.38126">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M13.7734 4.66663L12.6187 5.33329">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M14.6668 8L13.3335 8">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M13.7734 11.3333L12.6187 10.6666">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M11.3332 13.7735L10.6665 12.6188">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M8 14.6667L8 13.3334">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M4.6665 13.7735L5.33317 12.6188">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M2.22656 11.3333L3.38127 10.6666">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M1.3335 8L2.66683 8">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M2.22656 4.66663L3.38127 5.33329">
-  </path>
-  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M4.6665 2.22656L5.33317 3.38126">
-  </path>
+  <g>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M8 1.33337L8 2.66671">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M11.3332 2.22656L10.6665 3.38126">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M13.7734 4.66663L12.6187 5.33329">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M14.6668 8L13.3335 8">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M13.7734 11.3333L12.6187 10.6666">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M11.3332 13.7735L10.6665 12.6188">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M8 14.6667L8 13.3334">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M4.6665 13.7735L5.33317 12.6188">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M2.22656 11.3333L3.38127 10.6666">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M1.3335 8L2.66683 8">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M2.22656 4.66663L3.38127 5.33329">
+    </path>
+    <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+      d="M4.6665 2.22656L5.33317 3.38126">
+    </path>
+    <animateTransform values="360 8 8;0 8 8" keyTimes="0;1" dur="1.2" 
repeatCount="indefinite" type="rotate"
+      attributeName="transform"></animateTransform>
+  </g>
+
 </svg>
\ No newline at end of file
diff --git a/bigtop-manager-ui/src/assets/images/svg/processing.svg 
b/bigtop-manager-ui/src/assets/images/svg/processing.svg
index 015797f8..9f553893 100644
--- a/bigtop-manager-ui/src/assets/images/svg/processing.svg
+++ b/bigtop-manager-ui/src/assets/images/svg/processing.svg
@@ -21,8 +21,12 @@
   viewBox="0 0 16 16" fill="none">
   <path stroke="rgba(22, 119, 255, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
     d="M1.3335 7.99992C1.3335 11.6818 4.31826 14.6666 8.00016 14.6666C11.6821 
14.6666 14.6668 11.6818 14.6668 7.99992C14.6668 4.31802 11.6821 1.33325 8.00016 
1.33325">
+    <animateTransform values="0 8 8;360 8 8" keyTimes="0;1" dur="1" 
repeatCount="indefinite" type="rotate"
+      attributeName="transform"></animateTransform>
   </path>
   <path stroke="rgba(22, 119, 255, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
     d="M12 8C12 5.79087 10.2091 4 8 4C5.79087 4 4 5.79087 4 8C4 10.2091 
5.79087 12 8 12">
+    <animateTransform values="360 8 8;0 8 8" keyTimes="0;1" dur="1.2" 
repeatCount="indefinite" type="rotate"
+      attributeName="transform"></animateTransform>
   </path>
 </svg>
\ No newline at end of file
diff --git a/bigtop-manager-ui/src/assets/images/svg/question.svg 
b/bigtop-manager-ui/src/assets/images/svg/question.svg
index 79a6923c..9dbac282 100644
--- a/bigtop-manager-ui/src/assets/images/svg/question.svg
+++ b/bigtop-manager-ui/src/assets/images/svg/question.svg
@@ -16,7 +16,8 @@
   ~ KIND, either express or implied.  See the License for the
   ~ specific language governing permissions and limitations
   ~ under the License.
---><svg xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; width="16" height="16"
+-->
+<svg xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; width="16" height="16"
     viewBox="0 0 16 16" fill="none">
     <g opacity="0">
         <rect x="0" y="0" width="16" height="16" fill="#000000">
diff --git a/bigtop-manager-ui/src/assets/images/svg/processing.svg 
b/bigtop-manager-ui/src/assets/images/svg/remove.svg
similarity index 66%
copy from bigtop-manager-ui/src/assets/images/svg/processing.svg
copy to bigtop-manager-ui/src/assets/images/svg/remove.svg
index 015797f8..2879ebbe 100644
--- a/bigtop-manager-ui/src/assets/images/svg/processing.svg
+++ b/bigtop-manager-ui/src/assets/images/svg/remove.svg
@@ -19,10 +19,7 @@
 -->
 <svg xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; width="16" height="16"
   viewBox="0 0 16 16" fill="none">
-  <path stroke="rgba(22, 119, 255, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M1.3335 7.99992C1.3335 11.6818 4.31826 14.6666 8.00016 14.6666C11.6821 
14.6666 14.6668 11.6818 14.6668 7.99992C14.6668 4.31802 11.6821 1.33325 8.00016 
1.33325">
-  </path>
-  <path stroke="rgba(22, 119, 255, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M12 8C12 5.79087 10.2091 4 8 4C5.79087 4 4 5.79087 4 8C4 10.2091 
5.79087 12 8 12">
+  <path stroke="rgba(51, 51, 51, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
+    d="M3.5 8L12.8333 8">
   </path>
 </svg>
\ No newline at end of file
diff --git a/bigtop-manager-ui/src/assets/images/svg/processing.svg 
b/bigtop-manager-ui/src/assets/images/svg/retry.svg
similarity index 62%
copy from bigtop-manager-ui/src/assets/images/svg/processing.svg
copy to bigtop-manager-ui/src/assets/images/svg/retry.svg
index 015797f8..9b5199f3 100644
--- a/bigtop-manager-ui/src/assets/images/svg/processing.svg
+++ b/bigtop-manager-ui/src/assets/images/svg/retry.svg
@@ -17,12 +17,12 @@
   ~ specific language governing permissions and limitations
   ~ under the License.
 -->
-<svg xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; width="16" height="16"
-  viewBox="0 0 16 16" fill="none">
-  <path stroke="rgba(22, 119, 255, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M1.3335 7.99992C1.3335 11.6818 4.31826 14.6666 8.00016 14.6666C11.6821 
14.6666 14.6668 11.6818 14.6668 7.99992C14.6668 4.31802 11.6821 1.33325 8.00016 
1.33325">
+<svg xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; width="12" height="12"
+  viewBox="0 0 12 12" fill="none">
+  <path stroke="rgba(22, 119, 255, 1)" stroke-width="1" 
stroke-linejoin="round" stroke-linecap="round"
+    d="M9.18197 9.18197C8.36765 9.99632 7.24265 10.5 6 10.5C3.51472 10.5 1.5 
8.48528 1.5 6C1.5 3.51472 3.51472 1.5 6 1.5C7.24265 1.5 8.36765 2.00368 9.18197 
2.81803C9.59647 3.23252 10.5 4.25 10.5 4.25">
   </path>
-  <path stroke="rgba(22, 119, 255, 1)" stroke-width="1.3333333333333333" 
stroke-linejoin="round" stroke-linecap="round"
-    d="M12 8C12 5.79087 10.2091 4 8 4C5.79087 4 4 5.79087 4 8C4 10.2091 
5.79087 12 8 12">
+  <path stroke="rgba(22, 119, 255, 1)" stroke-width="1" 
stroke-linejoin="round" stroke-linecap="round"
+    d="M10.5 2L10.5 4.25L8.25 4.25">
   </path>
 </svg>
\ No newline at end of file
diff --git 
a/bigtop-manager-ui/src/components/create-service/components/service-configurator.vue
 
b/bigtop-manager-ui/src/components/create-service/components/service-configurator.vue
index 446d8a30..37073c1d 100644
--- 
a/bigtop-manager-ui/src/components/create-service/components/service-configurator.vue
+++ 
b/bigtop-manager-ui/src/components/create-service/components/service-configurator.vue
@@ -26,6 +26,7 @@
   import type { ServiceConfigReq } from '@/api/command/types'
   import type { ComponentVO } from '@/api/component/types'
   import type { Key } from 'ant-design-vue/es/_util/type'
+  import type { Property } from '@/api/service/types'
 
   interface Props {
     isView?: boolean
@@ -49,16 +50,16 @@
   })
   const layout = shallowRef({
     labelCol: {
-      xs: { span: 24 },
-      sm: { span: 8 },
-      md: { span: 8 },
-      lg: { span: 6 }
+      xs: { span: 23 },
+      sm: { span: 7 },
+      md: { span: 7 },
+      lg: { span: 5 }
     },
     wrapperCol: {
-      xs: { span: 24 },
-      sm: { span: 16 },
-      md: { span: 16 },
-      lg: { span: 18 }
+      xs: { span: 23 },
+      sm: { span: 15 },
+      md: { span: 15 },
+      lg: { span: 17 }
     }
   })
   const serviceList = computed(() => selectedServices.value)
@@ -77,7 +78,7 @@
     }
   )
 
-  const createNewConfigItem = () => {
+  const createNewProperty = () => {
     return {
       name: '',
       displayName: '',
@@ -86,8 +87,15 @@
     }
   }
 
-  const manualAddConfig = (config: ServiceConfigReq) => {
-    config.properties?.push(createNewConfigItem())
+  const manualAddPropertyForConfig = (config: ServiceConfigReq) => {
+    config.properties?.push(createNewProperty())
+  }
+
+  const removeProperty = (property: Property, config: ServiceConfigReq) => {
+    const index = config.properties.findIndex((v) => v.name === property.name)
+    if (index != -1) {
+      config.properties.splice(index, 1)
+    }
   }
 
   const handleChange = (expandSelectedKeyPath: string) => {
@@ -157,7 +165,12 @@
         <a-collapse v-model:active-key="activeKey" :bordered="false" 
:ghost="true">
           <a-collapse-panel v-for="config in filterConfigs" :key="config.id">
             <template #extra>
-              <a-button type="text" shape="circle" 
@click.stop="manualAddConfig(config)">
+              <a-button
+                v-if="!$props.isView && !disabled"
+                type="text"
+                shape="circle"
+                @click.stop="manualAddPropertyForConfig(config)"
+              >
                 <template #icon>
                   <svg-icon name="plus_dark" />
                 </template>
@@ -166,12 +179,22 @@
             <template #header>
               <span>{{ config.name }}</span>
             </template>
-            <a-row v-for="(item, idx) in config.properties" :key="idx" 
:gutter="[16, 0]" :wrap="true">
+            <a-row
+              v-for="(property, idx) in config.properties"
+              :key="idx"
+              justify="space-between"
+              :gutter="[16, 0]"
+              :wrap="true"
+            >
               <a-col v-bind="layout.labelCol">
                 <a-form-item>
-                  <a-textarea v-if="item.isManual" v-model:value="item.name" 
:auto-size="{ minRows: 1, maxRows: 5 }" />
-                  <span v-else style="overflow-wrap: break-word" 
:title="item.displayName ?? item.name">
-                    {{ item.displayName ?? item.name }}
+                  <a-textarea
+                    v-if="property.isManual"
+                    v-model:value="property.name"
+                    :auto-size="{ minRows: 1, maxRows: 5 }"
+                  />
+                  <span v-else style="overflow-wrap: break-word" 
:title="property.displayName ?? property.name">
+                    {{ property.displayName ?? property.name }}
                   </span>
 
                   <!-- <template v-else>
@@ -190,9 +213,19 @@
               </a-col>
               <a-col v-bind="layout.wrapperCol">
                 <a-form-item>
-                  <a-textarea v-model:value="item.value" :auto-size="{ 
minRows: 1, maxRows: 5 }" />
+                  <a-textarea v-model:value="property.value" :auto-size="{ 
minRows: 1, maxRows: 5 }" />
                 </a-form-item>
               </a-col>
+              <a-button
+                v-if="!$props.isView && !disabled"
+                type="text"
+                shape="circle"
+                @click="removeProperty(property, config)"
+              >
+                <template #icon>
+                  <svg-icon name="remove" />
+                </template>
+              </a-button>
             </a-row>
           </a-collapse-panel>
         </a-collapse>
diff --git a/bigtop-manager-ui/src/components/job/index.vue 
b/bigtop-manager-ui/src/components/job-modal/index.vue
similarity index 62%
copy from bigtop-manager-ui/src/components/job/index.vue
copy to bigtop-manager-ui/src/components/job-modal/index.vue
index f318fe34..1452077c 100644
--- a/bigtop-manager-ui/src/components/job/index.vue
+++ b/bigtop-manager-ui/src/components/job-modal/index.vue
@@ -18,30 +18,31 @@
 -->
 
 <script setup lang="ts">
-  import { computed, ComputedRef, onActivated, onDeactivated, reactive, ref, 
shallowRef } from 'vue'
+  import { computed, Reactive, reactive, ref, shallowRef, toRefs, watch } from 
'vue'
   import { useI18n } from 'vue-i18n'
-  import { useRoute } from 'vue-router'
-  import { TableColumnType, TableProps } from 'ant-design-vue'
-  import { getJobList, getStageList, getTaskList } from '@/api/job'
+  import { TableColumnType } from 'ant-design-vue'
   import useBaseTable from '@/composables/use-base-table'
   import LogsView, { type LogViewProps } from '@/components/log-view/index.vue'
-  import CustomProgress from './custom-progress.vue'
+  import CustomProgress from '@/components/job/custom-progress.vue'
   import type { JobVO, StageVO, StateType, TaskListParams, TaskVO } from 
'@/api/job/types'
+  import type { CommandRes, JobStageProgressItem } from '@/store/job-progress'
 
   interface BreadcrumbItem {
     id: string
-    name: ComputedRef<string> | string
+    name: string
+    tableData: (JobVO | StageVO | TaskVO)[]
   }
-  const POLLING_INTERVAL = 3000
+
+  interface Props {
+    execRes: CommandRes
+    jobInfo: Reactive<Map<number, JobStageProgressItem>>
+  }
+
+  const props = defineProps<Props>()
+  const { execRes, jobInfo } = toRefs(props)
+
   const { t } = useI18n()
-  const route = useRoute()
-  const pollingIntervalId = ref<any>(null)
-  const breadcrumbs = ref<BreadcrumbItem[]>([
-    {
-      name: computed(() => t('job.job_list')),
-      id: `clusterId-${route.params.id}`
-    }
-  ])
+  const breadcrumbs = ref<BreadcrumbItem[]>([])
   const status = shallowRef<Record<StateType, string>>({
     Pending: 'installing',
     Processing: 'processing',
@@ -51,16 +52,10 @@
   })
   const apiMap = shallowRef([
     {
-      key: 'clusterId',
-      api: getJobList
-    },
-    {
-      key: 'jobId',
-      api: getStageList
+      key: 'jobId'
     },
     {
-      key: 'stageId',
-      api: getTaskList
+      key: 'stageId'
     }
   ])
   const logsViewState = reactive<LogViewProps>({
@@ -97,99 +92,76 @@
       ellipsis: true
     }
   ])
-  const apiParams = computed(
-    (): TaskListParams =>
-      breadcrumbs.value.reduce((pre, val, index) => {
-        return Object.assign(pre, {
-          [`${apiMap.value[index].key}`]: 
val.id.replace(`${apiMap.value[index].key}-`, '')
-        })
-      }, {} as TaskListParams)
+  const apiParams = computed(() =>
+    breadcrumbs.value.reduce((pre, val, index) => {
+      const apiMapKey = apiMap.value[index].key
+      return Object.assign(pre, {
+        [`${apiMapKey}`]: val.id.replace(`${apiMapKey}-`, '')
+      })
+    }, {} as TaskListParams)
   )
 
-  const { dataSource, loading, filtersParams, paginationProps, onChange } = 
useBaseTable<JobVO | StageVO | TaskVO>({
-    columns: columns.value
+  const { dataSource, loading, paginationProps, onChange } = 
useBaseTable<JobVO | StageVO | TaskVO>({
+    columns: columns.value,
+    rows: []
   })
 
+  const currJobInfo = computed(() => jobInfo.value.get(execRes.value.jobId))
+
+  watch(
+    () => currJobInfo.value?.payLoad,
+    (val) => {
+      if (val) {
+        breadcrumbs.value[0] = {
+          name: val.name!,
+          id: `jobId-${val.id}`,
+          tableData: val.stages || []
+        }
+        dataSource.value = val.stages || []
+      }
+    },
+    {
+      immediate: true,
+      deep: true
+    }
+  )
+
   const clickBreadcrumb = (breadcrumb: BreadcrumbItem) => {
     if (breadcrumb.id !== currBreadcrumb.value?.id) {
       const index = breadcrumbs.value.findIndex((v) => v.id === breadcrumb.id)
       breadcrumbs.value.splice(index + 1, breadcrumbLen.value - index - 1)
-      stopPolling()
-      startPolling()
     }
+    dataSource.value = breadcrumb.tableData
   }
 
-  const updateBreadcrumbs = (data: BreadcrumbItem) => {
+  const updateBreadcrumbs = (data: JobVO | StageVO | TaskVO) => {
     if (breadcrumbLen.value + 1 > apiMap.value.length) {
       // task log open
       logsViewState.open = true
       logsViewState.subTitle = data.name as string
       logsViewState.payLoad = {
         ...apiParams.value,
-        taskId: parseInt(data.id)
+        taskId: Number(data.id),
+        clusterId: currJobInfo.value?.clusterId ?? 0
       }
       return
     }
-    const currId = `${apiMap.value[breadcrumbLen.value].key}-${data.id}`
+    const breadcrumbKey = apiMap.value[breadcrumbLen.value].key
+    const currId = `${breadcrumbKey}-${data.id}`
     const index = breadcrumbs.value.findIndex((v) => v.id === currId)
+    const newBreadcrumbItem = { id: currId, name: data.name! }
     if (index === -1) {
-      breadcrumbs.value.push({
-        id: currId,
-        name: data.name as string
-      })
-      stopPolling()
-      startPolling()
-    }
-  }
-
-  const getListData = async (isFirstCall = false) => {
-    if (!paginationProps.value) {
-      loading.value = false
-      return
-    }
-    try {
-      if (isFirstCall) {
-        loading.value = true
+      if (breadcrumbKey === 'jobId') {
+        breadcrumbs.value.push(Object.assign(newBreadcrumbItem, { tableData: 
data.stages }))
+        dataSource.value = data.stages
+      } else if (breadcrumbKey === 'stageId') {
+        breadcrumbs.value.push(Object.assign(newBreadcrumbItem, { tableData: 
data.tasks }))
+        dataSource.value = data.tasks
       }
-      const { api } = apiMap.value[breadcrumbs.value.length - 1]
-      const res = await api(apiParams.value, filtersParams.value)
-      dataSource.value = res.content
-      paginationProps.value.total = res.total
-    } catch (error) {
-      console.log('error :>> ', error)
-      stopPolling()
-    } finally {
-      loading.value = false
+    } else {
+      dataSource.value = breadcrumbs.value[index].tableData
     }
   }
-
-  const tableChange: TableProps['onChange'] = (...args) => {
-    onChange(...args)
-    stopPolling()
-    startPolling()
-  }
-
-  const startPolling = () => {
-    getListData(true)
-    pollingIntervalId.value = setInterval(() => {
-      getListData()
-    }, POLLING_INTERVAL)
-  }
-
-  const stopPolling = () => {
-    if (pollingIntervalId.value) {
-      clearInterval(pollingIntervalId.value)
-      pollingIntervalId.value = null
-    }
-  }
-
-  onActivated(() => {
-    startPolling()
-  })
-
-  onDeactivated(() => {
-    stopPolling()
-  })
 </script>
 
 <template>
@@ -200,6 +172,7 @@
           v-for="breadcrumb in breadcrumbs"
           :key="breadcrumb.id"
           class="header-title"
+          style="font-size: 16px"
           @click="clickBreadcrumb(breadcrumb)"
         >
           <a v-if="currBreadcrumb?.id != breadcrumb.id">{{ breadcrumb.name 
}}</a>
@@ -212,7 +185,7 @@
       :data-source="dataSource"
       :columns="columns"
       :pagination="paginationProps"
-      @change="tableChange"
+      @change="onChange"
     >
       <template #bodyCell="{ record, text, column }">
         <template v-if="column.key === 'name'">
@@ -228,6 +201,7 @@
     </a-table>
     <logs-view
       v-model:open="logsViewState.open"
+      width="46%"
       :pay-load="logsViewState.payLoad"
       :sub-title="logsViewState.subTitle"
     />
diff --git a/bigtop-manager-ui/src/components/job/custom-progress.vue 
b/bigtop-manager-ui/src/components/job/custom-progress.vue
index 897a8fc3..59971db9 100644
--- a/bigtop-manager-ui/src/components/job/custom-progress.vue
+++ b/bigtop-manager-ui/src/components/job/custom-progress.vue
@@ -20,18 +20,15 @@
 <script lang="ts" setup>
   import { computed } from 'vue'
   import { JobState } from '@/enums/state'
-  import {
-    MinusCircleFilled as Canceled,
-    CheckCircleFilled as Successful,
-    CloseCircleFilled as Failed
-  } from '@ant-design/icons-vue'
 
   interface ProgressProps {
     state: keyof typeof JobState
     progressData: number
   }
 
-  const props = withDefaults(defineProps<ProgressProps>(), {})
+  const props = withDefaults(defineProps<ProgressProps>(), {
+    progressData: 1
+  })
 
   const progressConfig = computed(() => {
     if (props.state === 'Pending') {
@@ -43,24 +40,25 @@
       return {
         progress: 100,
         status: 'success',
-        icon: Successful
+        icon: 'success'
       }
     } else if (props.state === 'Processing') {
       return {
-        progress: props.progressData,
+        progress: props.progressData ?? 1,
         status: 'active'
       }
     } else if (props.state === 'Canceled') {
       return {
         progress: 100,
         status: 'normal',
-        icon: Canceled
+        icon: 'canceled',
+        strokeColor: '#faad14'
       }
     } else if (props.state === 'Failed') {
       return {
         progress: 100,
         status: 'exception',
-        icon: Failed
+        icon: 'failed'
       }
     } else {
       return {
@@ -76,18 +74,25 @@
     <a-progress
       :percent="progressConfig.progress"
       :status="progressConfig.status"
-      :stroke-color="JobState[props.state]"
+      :stroke-color="progressConfig.strokeColor || ''"
     >
       <template #format="percent">
         <span v-if="['Processing', 'Pending'].includes(props.state)"> {{ 
percent }} % </span>
-        <component
-          :is="progressConfig.icon"
+        <svg-icon
           v-else-if="progressConfig.icon"
-          :style="{ color: JobState[props.state] }"
+          :style="{ margin: 0, 'vertical-align': '-0.5em' }"
+          :name="progressConfig.icon"
         />
       </template>
     </a-progress>
   </div>
 </template>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+  :deep(.ant-progress-line) {
+    margin-bottom: 0px !important;
+    .ant-progress-text {
+      line-height: 0;
+    }
+  }
+</style>
diff --git a/bigtop-manager-ui/src/components/job/index.vue 
b/bigtop-manager-ui/src/components/job/index.vue
index f318fe34..dd849522 100644
--- a/bigtop-manager-ui/src/components/job/index.vue
+++ b/bigtop-manager-ui/src/components/job/index.vue
@@ -152,7 +152,7 @@
         loading.value = true
       }
       const { api } = apiMap.value[breadcrumbs.value.length - 1]
-      const res = await api(apiParams.value, filtersParams.value)
+      const res = await api(apiParams.value, { ...filtersParams.value, order: 
'desc' })
       dataSource.value = res.content
       paginationProps.value.total = res.total
     } catch (error) {
diff --git a/bigtop-manager-ui/src/components/log-view/index.vue 
b/bigtop-manager-ui/src/components/log-view/index.vue
index 78e3b671..d1940ba5 100644
--- a/bigtop-manager-ui/src/components/log-view/index.vue
+++ b/bigtop-manager-ui/src/components/log-view/index.vue
@@ -42,6 +42,7 @@
 
   export interface LogViewProps {
     open: boolean
+    width?: string | number
     payLoad?: PayLoad
     subTitle?: string
   }
@@ -154,10 +155,11 @@
 
 <template>
   <a-modal
-    width="60%"
+    :width="$props.width || '60%'"
     style="min-width: 400px"
     :mask="false"
     :open="open"
+    :z-index="99999"
     :title="$t(`job.${title}`)"
     @ok="handleOk"
     @cancel="handleOk"
@@ -168,7 +170,7 @@
     <template #footer>
       <div class="log-load">
         <div>
-          <span v-show="loading" id="log-loading">{{ $t('job.log_loading') 
}}</span>
+          <span v-show="loading" class="text-loading">{{ $t('job.log_loading') 
}}</span>
         </div>
         <a-button key="submit" type="primary" @click="handleOk">
           {{ $t('common.confirm') }}
@@ -218,34 +220,4 @@
       margin: 0;
     }
   }
-
-  @keyframes loading-dots {
-    0% {
-      content: '';
-    }
-    16% {
-      content: '.';
-    }
-    32% {
-      content: '..';
-    }
-    48% {
-      content: '...';
-    }
-    80% {
-      content: '....';
-    }
-    96% {
-      content: '.....';
-    }
-    100% {
-      content: '......';
-    }
-  }
-
-  #log-loading::after {
-    content: '';
-    display: inline-block;
-    animation: loading-dots 1.5s infinite;
-  }
 </style>
diff --git a/bigtop-manager-ui/src/locales/en_US/common.ts 
b/bigtop-manager-ui/src/locales/en_US/common.ts
index 89aeb245..19782df5 100644
--- a/bigtop-manager-ui/src/locales/en_US/common.ts
+++ b/bigtop-manager-ui/src/locales/en_US/common.ts
@@ -124,5 +124,6 @@ export default {
   all: 'All',
   disabled: 'Disabled',
   restore: 'Restore',
-  selected: 'Selected'
+  selected: 'Selected',
+  feature_not_supported: 'Feature not supported yet'
 }
diff --git a/bigtop-manager-ui/src/locales/en_US/job.ts 
b/bigtop-manager-ui/src/locales/en_US/job.ts
index 3d015fda..535a7798 100644
--- a/bigtop-manager-ui/src/locales/en_US/job.ts
+++ b/bigtop-manager-ui/src/locales/en_US/job.ts
@@ -20,5 +20,9 @@
 export default {
   job_list: 'Job List',
   task_log: 'Task Log',
-  log_loading: 'Loading logs'
+  log_loading: 'Loading logs',
+  pending: 'Waiting to run',
+  processing: 'Running',
+  success: 'Run succeeded!',
+  failed: 'Run failed!'
 }
diff --git a/bigtop-manager-ui/src/locales/zh_CN/common.ts 
b/bigtop-manager-ui/src/locales/zh_CN/common.ts
index 0fdfffec..f7aab381 100644
--- a/bigtop-manager-ui/src/locales/zh_CN/common.ts
+++ b/bigtop-manager-ui/src/locales/zh_CN/common.ts
@@ -124,5 +124,6 @@ export default {
   all: '全部',
   disabled: '关闭',
   restore: '恢复',
-  selected: '选中'
+  selected: '选中',
+  feature_not_supported: '功能暂不支持'
 }
diff --git a/bigtop-manager-ui/src/locales/zh_CN/job.ts 
b/bigtop-manager-ui/src/locales/zh_CN/job.ts
index a76d0677..04710a84 100644
--- a/bigtop-manager-ui/src/locales/zh_CN/job.ts
+++ b/bigtop-manager-ui/src/locales/zh_CN/job.ts
@@ -20,5 +20,9 @@
 export default {
   job_list: '作业列表',
   task_log: '任务日志',
-  log_loading: '获取日志中'
+  log_loading: '获取日志中',
+  pending: '等待中',
+  processing: '运行中',
+  success: '运行成功!',
+  failed: '运行失败!'
 }
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/cluster/create.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/create.vue
index 37134201..944f2f98 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/create.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/cluster/create.vue
@@ -19,10 +19,10 @@
 
 <script setup lang="ts">
   import { useMenuStore } from '@/store/menu'
+  import { message } from 'ant-design-vue'
   import { computed, ref, shallowRef } from 'vue'
   import { useI18n } from 'vue-i18n'
   import { getInstalledStatus, installDependencies } from '@/api/hosts'
-  import { message } from 'ant-design-vue'
   import { InstalledStatusVO, Status } from '@/api/hosts/types'
   import { execCommand } from '@/api/command'
   import useSteps from '@/composables/use-steps'
@@ -30,7 +30,7 @@
   import ComponentInfo from './components/component-info.vue'
   import HostConfig from './components/host-config.vue'
   import CheckWorkflow from './components/check-workflow.vue'
-  import { ClusterCommandReq, type CommandRequest, type CommandVO, HostReq } 
from '@/api/command/types'
+  import type { ClusterCommandReq, CommandRequest, CommandVO, HostReq } from 
'@/api/command/types'
 
   const { t } = useI18n()
   const menuStore = useMenuStore()
@@ -51,6 +51,7 @@
       stepData.value[2].every((v) => v.status === Status.Success) &&
       hasUnknownHost.value
   )
+  const getCompName = computed(() => components.value[current.value])
   const isDone = computed(() => ['Successful', 
'Failed'].includes(stepData.value[stepData.value.length - 1].state))
   const steps = computed(() => [
     'cluster.cluster_info',
@@ -59,10 +60,6 @@
     'cluster.create'
   ])
 
-  const getCompName = computed(() => {
-    return components.value[current.value]
-  })
-
   const { current, stepsLimit, previousStep, nextStep } = useSteps(steps.value)
 
   const updateData = (val: Partial<ClusterCommandReq> | any | HostReq[]) => {
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/cluster/index.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/index.vue
index 4c42360a..61784027 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/index.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/cluster/index.vue
@@ -22,21 +22,22 @@
   import { useI18n } from 'vue-i18n'
   import { useClusterStore } from '@/store/cluster'
   import { storeToRefs } from 'pinia'
-  import { execCommand } from '@/api/command'
-  import { Command } from '@/api/command/types'
   import { CommonStatus, CommonStatusTexts } from '@/enums/state'
   import { useRouter } from 'vue-router'
+  import { useJobProgress } from '@/store/job-progress'
   import Overview from './overview.vue'
   import Service from './service.vue'
   import Host from './host.vue'
   import User from './user.vue'
   import Job from '@/components/job/index.vue'
+  import type { Command } from '@/api/command/types'
   import type { TabItem } from '@/components/common/main-card/types'
   import type { GroupItem } from '@/components/common/button-group/types'
   import type { ClusterStatusType } from '@/api/cluster/types'
 
   const { t } = useI18n()
   const router = useRouter()
+  const jobProgressStore = useJobProgress()
   const clusterStore = useClusterStore()
   const { currCluster, loading } = storeToRefs(clusterStore)
   const activeKey = ref('1')
@@ -103,13 +104,17 @@
 
   const dropdownMenuClick: GroupItem['dropdownMenuClickEvent'] = async ({ key 
}) => {
     try {
-      await execCommand({
-        command: key as keyof typeof Command,
-        clusterId: currCluster.value.id,
-        commandLevel: 'cluster'
-      })
-      await clusterStore.loadClusters()
-      await clusterStore.getClusterDetail()
+      await jobProgressStore.processCommand(
+        {
+          command: key as keyof typeof Command,
+          clusterId: currCluster.value.id,
+          commandLevel: 'cluster'
+        },
+        async () => {
+          await clusterStore.loadClusters()
+          await clusterStore.getClusterDetail()
+        }
+      )
     } catch (error) {
       console.log('error :>> ', error)
     }
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
index 714eb11e..848c244d 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
@@ -21,16 +21,19 @@
   import { computed, onActivated, ref, shallowRef, useAttrs } from 'vue'
   import { useI18n } from 'vue-i18n'
   import { storeToRefs } from 'pinia'
-  import { useServiceStore } from '@/store/service'
   import { formatFromByte } from '@/utils/storage'
   import { usePngImage } from '@/utils/tools'
   import { CommonStatus, CommonStatusTexts } from '@/enums/state'
+  import { useServiceStore } from '@/store/service'
+  import { useJobProgress } from '@/store/job-progress'
+  import { Empty } from 'ant-design-vue'
   import GaugeChart from './components/gauge-chart.vue'
   import CategoryChart from './components/category-chart.vue'
   import type { ClusterStatusType, ClusterVO } from '@/api/cluster/types'
-  import { type MenuProps, Empty } from 'ant-design-vue'
-  import type { ServiceListParams } from '@/api/service/types'
+  import type { ServiceListParams, ServiceVO } from '@/api/service/types'
+  import type { MenuItem } from '@/store/menu/types'
   import type { StackVO } from '@/api/stack/types'
+  import type { Command } from '@/api/command/types'
 
   type TimeRangeText = '1m' | '15m' | '30m' | '1h' | '6h' | '30h'
   type TimeRangeItem = {
@@ -40,6 +43,7 @@
 
   const { t } = useI18n()
   const attrs = useAttrs() as ClusterVO
+  const jobProgressStore = useJobProgress()
   const serviceStore = useServiceStore()
   const currTimeRange = ref<TimeRangeText>('15m')
   const chartData = ref({
@@ -54,6 +58,7 @@
     3: 'unknown'
   })
   const { locateStackWithService, serviceNames } = storeToRefs(serviceStore)
+
   const clusterDetail = computed(() => ({
     ...attrs,
     totalMemory: formatFromByte(attrs.totalMemory as number),
@@ -109,21 +114,30 @@
   const detailKeys = computed(() => Object.keys(baseConfig.value) as (keyof 
ClusterVO)[])
   const serviceOperates = computed(() => [
     {
-      action: 'start',
+      action: 'Start',
       text: t('common.start', [t('common.service')])
     },
     {
-      action: 'restart',
+      action: 'Restart',
       text: t('common.restart', [t('common.service')])
     },
     {
-      action: 'stop',
+      action: 'Stop',
       text: t('common.stop', [t('common.service')])
     }
   ])
 
-  const handleServiceOperate: MenuProps['onClick'] = (item) => {
-    console.log('item :>> ', item.key)
+  const handleServiceOperate = async (item: MenuItem, service: ServiceVO) => {
+    try {
+      await jobProgressStore.processCommand({
+        command: item.key as keyof typeof Command,
+        clusterId: attrs.id,
+        commandLevel: 'service',
+        serviceCommands: [{ serviceName: service.name!, installed: true }]
+      })
+    } catch (error) {
+      console.log('error :>> ', error)
+    }
   }
 
   const handleTimeRange = (time: TimeRangeItem) => {
@@ -229,7 +243,7 @@
                   <svg-icon name="more" style="margin: 0" />
                 </a-button>
                 <template #overlay>
-                  <a-menu @click="handleServiceOperate">
+                  <a-menu @click="handleServiceOperate($event, service)">
                     <a-menu-item v-for="operate in serviceOperates" 
:key="operate.action">
                       <span>{{ operate.text }}</span>
                     </a-menu-item>
diff --git 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/components.vue
 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/components.vue
index 89ed8599..35505783 100644
--- 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/components.vue
+++ 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/components.vue
@@ -23,11 +23,11 @@
   import { useI18n } from 'vue-i18n'
   import { useRoute } from 'vue-router'
   import { deleteComponent, getComponents } from '@/api/component'
-  import { execCommand } from '@/api/command'
   import { useStackStore } from '@/store/stack'
+  import { useJobProgress } from '@/store/job-progress'
   import useBaseTable from '@/composables/use-base-table'
-  import type { GroupItem } from '@/components/common/button-group/types'
   import { message, Modal, type TableColumnType, type TableProps } from 
'ant-design-vue'
+  import type { GroupItem } from '@/components/common/button-group/types'
   import type { ComponentVO } from '@/api/component/types'
   import type { FilterConfirmProps, FilterResetProps } from 
'ant-design-vue/es/table/interface'
   import type { Command, CommandRequest } from '@/api/command/types'
@@ -49,6 +49,7 @@
 
   const POLLING_INTERVAL = 3000
   const { t } = useI18n()
+  const jobProgressStore = useJobProgress()
   const stackStore = useStackStore()
   const route = useRoute()
   const attrs = useAttrs()
@@ -58,7 +59,7 @@
   const componentStatus = ref(['INSTALLING', 'SUCCESS', 'FAILED', 'UNKNOWN'])
   const commandRequest = shallowRef<CommandRequest>({
     command: 'Add',
-    commandLevel: 'cluster',
+    commandLevel: 'component',
     componentCommands: []
   })
   const state = reactive<TableState>({
@@ -233,14 +234,18 @@
       hostnames: [row.hostname!]
     })
     await execOperation()
-    getComponentList(true, true)
   }
 
   const execOperation = async () => {
     try {
-      await execCommand({ ...commandRequest.value, clusterId: 
currServiceInfo.value.id })
-      state.selectedRowKeys = []
-      state.selectedRows = []
+      await jobProgressStore.processCommand(
+        { ...commandRequest.value, clusterId: currServiceInfo.value.id },
+        async () => {
+          getComponentList(true, true)
+          state.selectedRowKeys = []
+          state.selectedRows = []
+        }
+      )
     } catch (error) {
       console.log('error :>> ', error)
     }
diff --git 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/configs.vue
 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/configs.vue
index 66c9b094..8515026b 100644
--- 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/configs.vue
+++ 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/configs.vue
@@ -25,7 +25,7 @@
   import { updateServiceConfigs } from '@/api/service'
   import CaptureSnapshot from './components/capture-snapshot.vue'
   import SnapshotManagement from './components/snapshot-management.vue'
-  import type { ServiceConfig, ServiceVO } from '@/api/service/types'
+  import type { Property, ServiceConfig, ServiceVO } from '@/api/service/types'
 
   interface ServieceInfo {
     cluster: string
@@ -48,20 +48,20 @@
   const debouncedOnSearch = ref()
   const layout = shallowRef({
     labelCol: {
-      xs: { span: 24 },
-      sm: { span: 8 },
-      md: { span: 8 },
-      lg: { span: 6 }
+      xs: { span: 23 },
+      sm: { span: 7 },
+      md: { span: 7 },
+      lg: { span: 5 }
     },
     wrapperCol: {
-      xs: { span: 24 },
-      sm: { span: 16 },
-      md: { span: 16 },
-      lg: { span: 18 }
+      xs: { span: 23 },
+      sm: { span: 15 },
+      md: { span: 15 },
+      lg: { span: 17 }
     }
   })
 
-  const createNewConfigItem = () => {
+  const createNewProperty = () => {
     return {
       name: '',
       displayName: '',
@@ -70,8 +70,15 @@
     }
   }
 
-  const manualAddConfig = (config: ServiceConfig) => {
-    config.properties?.push(createNewConfigItem())
+  const manualAddPropertyForConfig = (config: ServiceConfig) => {
+    config.properties?.push(createNewProperty())
+  }
+
+  const removeProperty = (property: Property, config: ServiceConfig) => {
+    const index = (config.properties || []).findIndex((v) => v.name === 
property.name)
+    if (index != -1) {
+      config.properties?.splice(index, 1)
+    }
   }
 
   const filterConfigurations = () => {
@@ -116,7 +123,8 @@
     snapshotRef.value?.handleOpen({ id: serviceId, clusterId })
   }
 
-  onActivated(() => {
+  onActivated(async () => {
+    await getServiceDetail()
     configs.value = attrs.configs as ServiceConfig[]
     filterConfigs.value = [...configs.value]
     activeKey.value = filterConfigs.value.map((v) => v.id!)
@@ -150,7 +158,7 @@
         <a-collapse v-model:active-key="activeKey" :bordered="false" 
:ghost="true">
           <a-collapse-panel v-for="config in filterConfigs" :key="config.id">
             <template #extra>
-              <a-button type="text" shape="circle" 
@click.stop="manualAddConfig(config)">
+              <a-button type="text" shape="circle" 
@click.stop="manualAddPropertyForConfig(config)">
                 <template #icon>
                   <svg-icon name="plus_dark" />
                 </template>
@@ -159,20 +167,35 @@
             <template #header>
               <span>{{ config.name }}</span>
             </template>
-            <a-row v-for="(item, idx) in config.properties" :key="idx" 
:gutter="[16, 0]" :wrap="true">
+            <a-row
+              v-for="(property, idx) in config.properties"
+              :key="idx"
+              justify="space-between"
+              :gutter="[16, 0]"
+              :wrap="true"
+            >
               <a-col v-bind="layout.labelCol">
                 <a-form-item>
-                  <a-textarea v-if="item.isManual" v-model:value="item.name" 
:auto-size="{ minRows: 1, maxRows: 5 }" />
-                  <span v-else style="overflow-wrap: break-word" 
:title="item.displayName ?? item.name">
-                    {{ item.displayName ?? item.name }}
+                  <a-textarea
+                    v-if="property.isManual"
+                    v-model:value="property.name"
+                    :auto-size="{ minRows: 1, maxRows: 5 }"
+                  />
+                  <span v-else style="overflow-wrap: break-word" 
:title="property.displayName ?? property.name">
+                    {{ property.displayName ?? property.name }}
                   </span>
                 </a-form-item>
               </a-col>
               <a-col v-bind="layout.wrapperCol">
                 <a-form-item>
-                  <a-textarea v-model:value="item.value" :auto-size="{ 
minRows: 1, maxRows: 5 }" />
+                  <a-textarea v-model:value="property.value" :auto-size="{ 
minRows: 1, maxRows: 5 }" />
                 </a-form-item>
               </a-col>
+              <a-button type="text" shape="circle" 
@click="removeProperty(property, config)">
+                <template #icon>
+                  <svg-icon name="remove" />
+                </template>
+              </a-button>
             </a-row>
           </a-collapse-panel>
         </a-collapse>
diff --git 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/index.vue
 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/index.vue
index 92491d5f..144904cb 100644
--- 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/index.vue
+++ 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service-management/index.vue
@@ -21,14 +21,13 @@
   import { computed, onMounted, provide, ref, shallowRef } from 'vue'
   import { useI18n } from 'vue-i18n'
   import { storeToRefs } from 'pinia'
-  // import { execCommand } from '@/api/command'
-  // import { Command } from '@/api/command/types'
-  // import { CommonStatus, CommonStatusTexts } from '@/enums/state'
   import { useServiceStore } from '@/store/service'
+  import { useJobProgress } from '@/store/job-progress'
   import { useRoute } from 'vue-router'
   import Overview from './overview.vue'
   import Components from './components.vue'
   import Configs from './configs.vue'
+  import { Command } from '@/api/command/types'
   import type { TabItem } from '@/components/common/main-card/types'
   import type { GroupItem } from '@/components/common/button-group/types'
   import type { ServiceVO } from '@/api/service/types'
@@ -43,6 +42,7 @@
   const { t } = useI18n()
   const route = useRoute()
   const serviceStore = useServiceStore()
+  const jobProgressStore = useJobProgress()
   const { loading } = storeToRefs(serviceStore)
   const activeKey = ref('1')
   const serviceDetail = shallowRef<ServiceVO>()
@@ -79,7 +79,8 @@
           action: 'Stop',
           text: t('common.stop', [t('common.service')])
         }
-      ]
+      ],
+      dropdownMenuClickEvent: (info) => dropdownMenuClick && 
dropdownMenuClick(info)
     }
   ])
 
@@ -88,6 +89,22 @@
     return components[parseInt(activeKey.value) - 1]
   })
 
+  const dropdownMenuClick: GroupItem['dropdownMenuClickEvent'] = async ({ key 
}) => {
+    try {
+      await jobProgressStore.processCommand(
+        {
+          command: key as keyof typeof Command,
+          clusterId: routeParams.value.id,
+          commandLevel: 'service',
+          serviceCommands: [{ serviceName: routeParams.value.service, 
installed: true }]
+        },
+        getServiceDetail
+      )
+    } catch (error) {
+      console.log('error :>> ', error)
+    }
+  }
+
   const getServiceDetail = async () => {
     try {
       loading.value = true
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service.vue
index 0f513c3e..03534884 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/service.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/cluster/service.vue
@@ -25,15 +25,20 @@
   import { useServiceStore } from '@/store/service'
   import { CommonStatus, CommonStatusTexts } from '@/enums/state'
   import { useRouter } from 'vue-router'
+  import { useJobProgress } from '@/store/job-progress'
   import FilterForm from '@/components/common/filter-form/index.vue'
   import type { GroupItem } from '@/components/common/button-group/types'
   import type { FilterFormItem } from '@/components/common/filter-form/types'
   import type { ServiceListParams, ServiceStatusType, ServiceVO } from 
'@/api/service/types'
   import type { ClusterVO } from '@/api/cluster/types'
+  import type { Command, CommandRequest } from '@/api/command/types'
+
+  type GroupItemActionType = keyof typeof Command | 'More'
 
   const { t } = useI18n()
   const attrs = useAttrs() as ClusterVO
   const router = useRouter()
+  const jobProgressStore = useJobProgress()
   const serviceStore = useServiceStore()
   const { services, loading } = toRefs(serviceStore)
   const statusColors = shallowRef<Record<ServiceStatusType, keyof typeof 
CommonStatusTexts>>({
@@ -42,33 +47,33 @@
     3: 'unknown'
   })
 
-  const actionGroups = shallowRef<GroupItem[]>([
+  const actionGroups = shallowRef<GroupItem<GroupItemActionType>[]>([
     {
-      action: 'start',
+      action: 'Start',
       icon: 'start',
-      clickEvent: (item) => {
-        console.log('item :>> ', item?.action)
+      clickEvent: (item, args) => {
+        handleServiceOperate(item!.action!, args)
       }
     },
     {
-      action: 'stop',
+      action: 'Stop',
       icon: 'stop',
-      clickEvent: (item) => {
-        console.log('item :>> ', item?.action)
+      clickEvent: (item, args) => {
+        handleServiceOperate(item!.action!, args)
       }
     },
     {
-      action: 'restart',
+      action: 'Restart',
       icon: 'restart',
-      clickEvent: (item) => {
-        console.log('item :>> ', item?.action)
+      clickEvent: (item, args) => {
+        handleServiceOperate(item!.action!, args)
       }
     },
     {
-      action: 'more',
+      action: 'More',
       icon: 'more_line',
-      clickEvent: (item) => {
-        console.log('item :>> ', item?.action)
+      clickEvent: (item, args) => {
+        handleServiceOperate(item!.action!, args)
       }
     }
   ])
@@ -115,6 +120,18 @@
     }
   ])
 
+  const handleServiceOperate = (command: GroupItemActionType, service: 
ServiceVO) => {
+    if (command !== 'More') {
+      const execCommandParams = {
+        command: command,
+        clusterId: attrs.id,
+        commandLevel: 'service',
+        serviceCommands: [{ serviceName: service.name, installed: true }]
+      } as CommandRequest
+      jobProgressStore.processCommand(execCommandParams, getServices)
+    }
+  }
+
   const getServices = (filters?: ServiceListParams) => {
     attrs.id != undefined && serviceStore.getServices(attrs.id, filters)
   }
@@ -166,7 +183,7 @@
           </div>
         </div>
         <div class="item-content" @click.stop>
-          <button-group :auto="true" :space="0" :groups="actionGroups">
+          <button-group :auto="true" :space="0" :args="item" 
:groups="actionGroups">
             <template #icon="{ item: groupItem }">
               <svg-icon :name="groupItem.icon || ''" />
             </template>
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue
index 84d221e9..df6909ca 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue
@@ -23,6 +23,7 @@
   import { useRoute } from 'vue-router'
   import { getHost } from '@/api/hosts'
   import { CommonStatus, CommonStatusTexts } from '@/enums/state'
+  import { message } from 'ant-design-vue'
   import Overview from './overview.vue'
   import type { GroupItem } from '@/components/common/button-group/types'
   import type { HostStatusType, HostVO } from '@/api/hosts/types.ts'
@@ -59,28 +60,19 @@
     }
   ])
 
-  const dropdownMenuClick: GroupItem['dropdownMenuClickEvent'] = async ({ key 
}) => {
-    console.log(key)
-    // try {
-    //   await execCommand({
-    //     command: key as keyof typeof Command,
-    //     clusterId: currCluster.value.id,
-    //     commandLevel: 'cluster'
-    //   })
-    //   await clusterStore.loadClusters()
-    //   await clusterStore.getClusterDetail()
-    // } catch (error) {
-    //   console.log('error :>> ', error)
-    // }
+  const dropdownMenuClick: GroupItem['dropdownMenuClickEvent'] = async () => {
+    message.error(t('common.feature_not_supported'))
   }
 
   const setupHostInfo = async () => {
     try {
       loading.value = true
       const hostId = route.query.hostId
+      const clusterId = route.query.clusterId
       const data = await getHost({ id: Number(hostId) })
       hostInfo.value = {
-        ...data
+        ...data,
+        clusterId
       }
     } catch (error) {
       console.log(error)
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/hosts/index.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/index.vue
index 1b2269a4..942ee00a 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/hosts/index.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/index.vue
@@ -22,11 +22,11 @@
   import { computed, onMounted, onUnmounted, reactive, ref, shallowRef } from 
'vue'
   import { useRouter } from 'vue-router'
   import { useI18n } from 'vue-i18n'
+  import { useClusterStore } from '@/store/cluster'
   import * as hostApi from '@/api/hosts'
   import useBaseTable from '@/composables/use-base-table'
   import HostCreate from '@/pages/cluster-manage/hosts/create.vue'
   import InstallDependencies from './install-dependencies.vue'
-  import { useClusterStore } from '@/store/cluster'
   import type { FilterConfirmProps, FilterResetProps, TableRowSelection } from 
'ant-design-vue/es/table/interface'
   import type { HostReq } from '@/api/command/types'
   import type { GroupItem } from '@/components/common/button-group/types'
@@ -262,7 +262,12 @@
   }
 
   const viewHostDetail = (row: HostVO) => {
-    router.push({ name: 'HostDetail', query: { hostId: row.id } })
+    const { clusterDisplayName, id: hostId } = row
+    const index = filtersOfClusterDisplayName.value.findIndex((v) => v.text 
=== clusterDisplayName)
+    if (index != -1) {
+      const clusterId = filtersOfClusterDisplayName.value[index].value
+      router.push({ name: 'HostDetail', query: { hostId, clusterId } })
+    }
   }
 
   onUnmounted(() => {
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue
index e1c2f6be..ae19d107 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue
@@ -20,16 +20,21 @@
 <script setup lang="ts">
   import { computed, ref, shallowRef, toRefs, watch } from 'vue'
   import { useI18n } from 'vue-i18n'
-  import { Empty, MenuProps } from 'ant-design-vue'
+  import { Empty } from 'ant-design-vue'
   import { formatFromByte } from '@/utils/storage.ts'
   import { usePngImage } from '@/utils/tools'
   import { CommonStatus, CommonStatusTexts } from '@/enums/state.ts'
+  import { useJobProgress } from '@/store/job-progress'
+  import { useStackStore } from '@/store/stack'
+  import { useInstalledStore } from '@/store/installed'
+  import { getComponentsByHost } from '@/api/hosts'
+  import { Command } from '@/api/command/types'
   import CategoryChart from 
'@/pages/cluster-manage/cluster/components/category-chart.vue'
   import GaugeChart from 
'@/pages/cluster-manage/cluster/components/gauge-chart.vue'
-  import { getComponentsByHost } from '@/api/hosts'
   import type { HostStatusType, HostVO } from '@/api/hosts/types.ts'
   import type { ClusterStatusType } from '@/api/cluster/types.ts'
   import type { ComponentVO } from '@/api/component/types.ts'
+  import type { MenuItem } from '@/store/menu/types'
 
   type TimeRangeText = '1m' | '15m' | '30m' | '1h' | '6h' | '30h'
   type TimeRangeItem = {
@@ -44,7 +49,9 @@
   const props = defineProps<Props>()
   const { hostInfo } = toRefs(props)
   const { t } = useI18n()
-  const currExecuteComponent = shallowRef<ComponentVO>({})
+  const stackStore = useStackStore()
+  const jobProgressStore = useJobProgress()
+  const installedStore = useInstalledStore()
   const currTimeRange = ref<TimeRangeText>('15m')
   const statusColors = shallowRef<Record<HostStatusType, keyof typeof 
CommonStatusTexts>>({
     1: 'healthy',
@@ -111,49 +118,66 @@
   const detailKeys = computed((): (keyof HostVO)[] => 
Object.keys(baseConfig.value))
   const componentOperates = computed(() => [
     {
-      action: 'start',
+      action: 'Start',
       text: t('common.start', [t('common.component')])
     },
     {
-      action: 'restart',
+      action: 'Restart',
       text: t('common.restart', [t('common.component')])
     },
     {
-      action: 'stop',
+      action: 'Stop',
       text: t('common.stop', [t('common.component')])
     }
   ])
 
-  const handleHostOperate: MenuProps['onClick'] = (item) => {
-    console.log('item :>> ', item.key)
-    console.log(currExecuteComponent.value)
+  const handleHostOperate = async (item: MenuItem, component: ComponentVO) => {
+    const { serviceName } = component
+    const installedServiceMap = 
Object.values(installedStore.installedServiceMap)
+      .flat()
+      .filter((v) => v.serviceName === serviceName)
+    if (installedServiceMap.length > 0) {
+      try {
+        await jobProgressStore.processCommand(
+          {
+            command: item.key as keyof typeof Command,
+            clusterId: installedServiceMap[0].clusterId,
+            commandLevel: 'component',
+            componentCommands: [{ componentName: component.name!, hostnames: 
[component.hostname!] }]
+          },
+          getComponentInfo
+        )
+      } catch (error) {
+        console.log('error :>> ', error)
+      }
+    }
   }
 
   const handleTimeRange = (time: TimeRangeItem) => {
     currTimeRange.value = time.text
   }
 
-  const recordClickComp = (comp: ComponentVO) => {
-    currExecuteComponent.value = comp
+  const getComponentInfo = async () => {
+    try {
+      const data = await getComponentsByHost({ id: hostInfo.value.id! })
+      componentsFromCurrentHost.value = data.reduce((pre, val) => {
+        if (!pre.has(val.stack!)) {
+          pre.set(val.stack!, [val])
+        } else {
+          ;(pre.get(val.stack!) as ComponentVO[]).push(val)
+        }
+        return pre
+      }, new Map<string, ComponentVO[]>())
+    } catch (error) {
+      console.log(error)
+    }
   }
 
   watch(
     () => hostInfo.value,
-    async (val) => {
+    (val) => {
       if (val.id) {
-        try {
-          const data = await getComponentsByHost({ id: val.id })
-          componentsFromCurrentHost.value = data.reduce((pre, val) => {
-            if (!pre.has(val.stack!)) {
-              pre.set(val.stack!, [val])
-            } else {
-              ;(pre.get(val.stack!) as ComponentVO[]).push(val)
-            }
-            return pre
-          }, new Map<string, ComponentVO[]>())
-        } catch (error) {
-          console.log(error)
-        }
+        getComponentInfo()
       }
     }
   )
@@ -239,12 +263,15 @@
             <div v-for="comp in componentsFromCurrentHost.get(stack)" 
:key="comp.id" class="component-item">
               <a-avatar v-if="comp.serviceName" 
:src="usePngImage(comp.serviceName.toLowerCase())" :size="22" />
               <a-typography-text 
:content="`${comp.serviceDisplayName}/${comp.displayName}`" />
-              <a-dropdown :trigger="['click']">
-                <a-button type="text" shape="circle" size="small" 
@click="recordClickComp(comp)">
+              <a-dropdown
+                
v-if="stackStore.stackRelationMap?.components[comp.name!].category != 'client'"
+                :trigger="['click']"
+              >
+                <a-button type="text" shape="circle" size="small">
                   <svg-icon name="more" style="margin: 0" />
                 </a-button>
                 <template #overlay>
-                  <a-menu @click="handleHostOperate">
+                  <a-menu @click="handleHostOperate($event, comp)">
                     <a-menu-item v-for="operate in componentOperates" 
:key="operate.action">
                       <span>{{ operate.text }}</span>
                     </a-menu-item>
diff --git 
a/bigtop-manager-ui/src/pages/cluster-manage/infrastructures/index.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/infrastructures/index.vue
index 1b5c0f75..cd257dfc 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/infrastructures/index.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/infrastructures/index.vue
@@ -24,14 +24,18 @@
   import { CommonStatus, CommonStatusTexts } from '@/enums/state'
   import { useServiceStore } from '@/store/service'
   import { useRouter } from 'vue-router'
+  import { useJobProgress } from '@/store/job-progress'
+  import { Empty } from 'ant-design-vue'
   import type { ServiceListParams, ServiceStatusType, ServiceVO } from 
'@/api/service/types'
   import type { GroupItem } from '@/components/common/button-group/types'
   import type { FilterFormItem } from '@/components/common/filter-form/types'
-  // import { execCommand } from '@/api/command';
-  import type { Command } from '@/api/command/types'
+  import type { Command, CommandRequest } from '@/api/command/types'
+
+  type GroupItemActionType = keyof typeof Command | 'More'
 
   const { t } = useI18n()
   const router = useRouter()
+  const jobProgressStore = useJobProgress()
   const serviceStore = useServiceStore()
   const { services, loading } = toRefs(serviceStore)
 
@@ -62,49 +66,47 @@
       ]
     }
   ])
-  const actionGroups = shallowRef<GroupItem[]>([
+  const actionGroups = shallowRef<GroupItem<GroupItemActionType>[]>([
     {
-      action: 'start',
+      action: 'Start',
       icon: 'start',
       clickEvent: (item, args) => {
-        console.log('item :>> ', item?.action)
-        infraAction('Start', args.name)
+        infraAction(item!.action!, args)
       }
     },
     {
-      action: 'stop',
+      action: 'Stop',
       icon: 'stop',
       clickEvent: (item, args) => {
-        console.log('item :>> ', item?.action)
-        infraAction('Stop', args.name)
+        infraAction(item!.action!, args)
       }
     },
     {
-      action: 'restart',
+      action: 'Restart',
       icon: 'restart',
       clickEvent: (item, args) => {
-        console.log('item :>> ', item?.action)
-        infraAction('Restart', args.name)
+        infraAction(item!.action!, args)
       }
     },
     {
-      action: 'more',
+      action: 'More',
       icon: 'more_line',
       clickEvent: (item, args) => {
-        console.log('item :>> ', item?.action)
-        infraAction('More', args.name)
+        infraAction(item!.action!, args)
       }
     }
   ])
 
-  const infraAction = async (command: keyof typeof Command, serviceName: 
string) => {
-    console.log(command, serviceName)
-    // await execCommand({
-    //   command: command,
-    //   clusterId: 0,
-    //   commandLevel: "service",
-    //   serviceCommands: [{ serviceName: serviceName }]
-    // })
+  const infraAction = async (command: GroupItemActionType, service: ServiceVO) 
=> {
+    if (command !== 'More') {
+      const execCommandParams = {
+        command: command,
+        clusterId: 0,
+        commandLevel: 'service',
+        serviceCommands: [{ serviceName: service.name, installed: true }]
+      } as CommandRequest
+      jobProgressStore.processCommand(execCommandParams, getServices)
+    }
   }
 
   const getServices = async (filters?: ServiceListParams) => {
@@ -141,7 +143,7 @@
     </div>
     <div class="infra-body">
       <filter-form :filter-items="filterFormItems" @filter="getServices" />
-      <a-empty v-if="services.length == 0" style="width: 100%" />
+      <a-empty v-if="services.length == 0" style="width: 100%" 
:image="Empty.PRESENTED_IMAGE_SIMPLE" />
       <template v-else>
         <a-card
           v-for="item in services"
diff --git a/bigtop-manager-ui/src/store/cluster/index.ts 
b/bigtop-manager-ui/src/store/cluster/index.ts
index e71a22dc..4380c728 100644
--- a/bigtop-manager-ui/src/store/cluster/index.ts
+++ b/bigtop-manager-ui/src/store/cluster/index.ts
@@ -62,6 +62,8 @@ export const useClusterStore = defineStore(
         currCluster.value = await getCluster(parseInt(clusterId.value))
         currCluster.value.id != undefined && (await 
serviceStore.getServices(currCluster.value.id))
       } catch (error) {
+        currCluster.value = {}
+        clusters.value = []
         console.log('error :>> ', error)
       } finally {
         loading.value = false
diff --git a/bigtop-manager-ui/src/store/installed/index.ts 
b/bigtop-manager-ui/src/store/installed/index.ts
index e56a2475..bb404d47 100644
--- a/bigtop-manager-ui/src/store/installed/index.ts
+++ b/bigtop-manager-ui/src/store/installed/index.ts
@@ -20,24 +20,37 @@
 import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import { useServiceStore } from '@/store/service'
-import { ServiceVO } from '@/api/service/types.ts'
+import type { ServiceVO } from '@/api/service/types.ts'
+
+export interface InstalledMapItem {
+  serviceId: number
+  serviceName: string
+  serviceDisplayName: string
+  clusterId: number
+}
 
 export const useInstalledStore = defineStore(
   'installed',
   () => {
     const serviceStore = useServiceStore()
-    const installedMap = ref<Record<string, string[]>>({})
+    const installedServiceMap = ref<Record<string, InstalledMapItem[]>>({})
 
     const getInstalledNamesOrIdsOfServiceByKey = (key: string, flag: 'names' | 
'ids' = 'names') => {
-      return installedMap.value[key].map((value) => value.split('-')[flag === 
'names' ? 1 : 0])
+      return installedServiceMap.value[key].map((value) => {
+        if (flag === 'ids') {
+          return value.serviceId
+        } else {
+          return value.serviceName
+        }
+      })
     }
 
     const setInstalledMapKey = (key: string) => {
-      installedMap.value[key] = []
+      installedServiceMap.value[key] = []
     }
 
-    const setInstalledMapKeyOfValue = (key: string, value: string[]) => {
-      installedMap.value[key] = value
+    const setInstalledMapKeyOfValue = (key: string, value: InstalledMapItem[]) 
=> {
+      installedServiceMap.value[key] = value
     }
 
     const getServicesOfInfra = async () => {
@@ -58,7 +71,7 @@ export const useInstalledStore = defineStore(
 
     return {
       serviceStore,
-      installedMap,
+      installedServiceMap,
       getServicesOfInfra,
       setInstalledMapKey,
       setInstalledMapKeyOfValue,
@@ -70,7 +83,7 @@ export const useInstalledStore = defineStore(
     persist: {
       storage: sessionStorage,
       key: 'installed',
-      paths: ['installedMap']
+      paths: ['installedServiceMap']
     }
   }
 )
diff --git a/bigtop-manager-ui/src/store/job-progress/index.ts 
b/bigtop-manager-ui/src/store/job-progress/index.ts
new file mode 100644
index 00000000..597ad2b8
--- /dev/null
+++ b/bigtop-manager-ui/src/store/job-progress/index.ts
@@ -0,0 +1,276 @@
+/*
+ * 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 { notification, Progress, Avatar, Button, Modal } from 'ant-design-vue'
+import { computed, getCurrentInstance, h, reactive, shallowRef } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { defineStore } from 'pinia'
+import { useClusterStore } from '@/store/cluster'
+import { execCommand } from '@/api/command'
+import { getJobDetails } from '@/api/job'
+import SvgIcon from '@/components/common/svg-icon/index.vue'
+import JobModal from '@/components/job-modal/index.vue'
+import type { CommandRequest } from '@/api/command/types'
+import type { JobParams, JobVO } from '@/api/job/types'
+
+export type StatusType = 'processing' | 'success' | 'failed' | 'pending'
+export type ExecFunc = (params: JobParams) => Promise<JobVO | undefined>
+export type CommandRes = { name: string } & JobParams
+export interface JobStageProgressItem extends Partial<CommandRequest> {
+  percent: number
+  status: 'success' | 'normal' | 'active' | 'exception'
+  icon: string
+  desc: string
+  payLoad?: JobVO
+}
+
+type JobStageProgress = Record<StatusType, () => JobStageProgressItem>
+
+export const useJobProgress = defineStore('job-progress', () => {
+  const { t } = useI18n()
+  const instance = getCurrentInstance()
+  const clusterStore = useClusterStore()
+  const progressMap = reactive<Map<number, JobStageProgressItem>>(new Map())
+  const jobStageProgress = shallowRef<JobStageProgress>({
+    pending: () => ({
+      percent: 0,
+      status: 'normal',
+      icon: 'processing',
+      desc: 'job.pending'
+    }),
+    processing: () => ({
+      percent: 1,
+      status: 'active',
+      icon: 'processing',
+      desc: 'job.processing'
+    }),
+    success: () => ({
+      percent: 100,
+      status: 'success',
+      icon: 'success',
+      desc: 'job.success'
+    }),
+    failed: () => ({
+      percent: 100,
+      status: 'exception',
+      icon: 'failed',
+      desc: 'job.failed'
+    })
+  })
+
+  const jobProgressStyle = computed(() => ({
+    desc: { display: 'flex', fontSize: '12px', 'justify-content': 
'space-between' },
+    avatar: { backgroundColor: 'transparent', border: 'none' },
+    icon: { padding: 0, margin: 0, height: '27px', width: '27px' },
+    retryBtn: {
+      display: 'flex',
+      padding: 0,
+      'align-items': 'center',
+      gap: '4px',
+      fontSize: '12px',
+      height: 'auto',
+      border: 'none'
+    }
+  }))
+
+  const getClusterDisplayName = (clusterId: number) => {
+    const clusters = clusterStore.clusters
+    const index = clusters.findIndex((v) => v.id == clusterId)
+    if (index != -1) {
+      return clusters[index].displayName
+    }
+    return 'Global'
+  }
+
+  const createStateIcon = (execRes: CommandRes) => {
+    return h(
+      Avatar,
+      { size: 27, shape: 'circle', style: jobProgressStyle.value.avatar },
+      {
+        icon: () =>
+          h(SvgIcon, {
+            name: progressMap.get(execRes.jobId)!.icon!,
+            style: jobProgressStyle.value.icon
+          })
+      }
+    )
+  }
+
+  const createStateProgress = (execRes: CommandRes, nextAction?: (...args: 
any) => void) => {
+    const progressData = progressMap.get(execRes.jobId)
+    return h('div', [
+      h(Progress, {
+        percent: progressData?.percent,
+        status: progressData?.status,
+        showInfo: false,
+        size: [300, 8]
+      }),
+      createStateDesc(execRes, nextAction)
+    ])
+  }
+
+  const createStateDesc = (execRes: CommandRes, nextAction?: (...args: any) => 
void) => {
+    const progressData = progressMap.get(execRes.jobId)
+    let retryJobOperation = h('span', `${progressData?.percent}%`)
+    if (progressData?.status === 'exception') {
+      retryJobOperation = h(
+        Button,
+        {
+          size: 'small',
+          type: 'link',
+          style: jobProgressStyle.value.retryBtn,
+          onClick: (e: Event) => retryJob(e, execRes, nextAction)
+        },
+        {
+          default: () => [
+            h('span', {}, t('common.retry')),
+            h(SvgIcon, {
+              name: 'retry',
+              style: { margin: 0 }
+            })
+          ]
+        }
+      )
+    }
+    return h('div', { style: jobProgressStyle.value.desc }, [
+      h('span', { class: { 'text-loading': progressData?.status === 'active' } 
}, `${t(`${progressData?.desc}`)}`),
+      retryJobOperation
+    ])
+  }
+
+  const retryJob = (e: Event, execRes: CommandRes, nextAction?: (...args: any) 
=> void) => {
+    e.stopPropagation()
+    Object.assign(progressMap.get(execRes.jobId)!, 
jobStageProgress.value.processing())
+    pollJobDetails(execRes, getJobInstanceDetails, 1000, nextAction)
+  }
+
+  const getJobInstanceDetails = async (params: JobParams) => {
+    try {
+      const data = await getJobDetails(params)
+      return data
+    } catch (error) {
+      console.log('error :>> ', error)
+    }
+  }
+
+  const pollJobDetails = (
+    params: JobParams,
+    execFunc: ExecFunc,
+    interval: number = 1000,
+    nextAction?: (...args: any) => void
+  ) => {
+    let intervalId: NodeJS.Timeout | null = null
+    const cleanup = () => {
+      if (intervalId) {
+        clearInterval(intervalId)
+        intervalId = null
+      }
+    }
+
+    const checkStatus = async () => {
+      try {
+        const result = await execFunc(params)
+        if (!result) {
+          progressMap.set(params.jobId, Object.assign(params, 
jobStageProgress.value.failed()))
+          cleanup()
+          return
+        }
+
+        const { state } = result
+        const jobId = params.jobId
+        const progressEntry = progressMap.get(jobId)
+        if (!progressEntry) {
+          cleanup()
+          return
+        }
+
+        const currentState = state === 'Successful' ? 'success' : 
state!.toLowerCase()
+        const targetProgress = jobStageProgress.value[`${currentState}`]() ?? 
progressEntry.percent
+        const oldPercent = progressEntry?.percent ?? 0
+        const mergeProgressPercent = { ...targetProgress, percent: oldPercent 
+ 2 }
+        progressEntry!.payLoad = result
+
+        if (state === 'Successful' || state === 'Failed') {
+          Object.assign(progressEntry!, targetProgress)
+          if (state === 'Successful') {
+            nextAction && nextAction()
+            closeNotification(params.jobId)
+          }
+          cleanup()
+        } else if (state === 'Processing') {
+          oldPercent < 90 && Object.assign(progressEntry, mergeProgressPercent)
+        }
+      } catch (error) {
+        console.log('error :>> ', error)
+      }
+    }
+    intervalId = setInterval(checkStatus, interval)
+  }
+
+  const openNotification = (execRes: CommandRes, nextAction?: (...args: any) 
=> void) => {
+    pollJobDetails(execRes, getJobInstanceDetails, 1000, nextAction)
+    notification.open({
+      key: `${execRes.jobId}`,
+      message: `${getClusterDisplayName(execRes.clusterId)} - ${execRes.name}`,
+      duration: null,
+      placement: 'bottomRight',
+      style: { padding: '16px' },
+      class: 'job-progress-notification',
+      icon: () => createStateIcon(execRes),
+      description: () => createStateProgress(execRes, nextAction),
+      onClick: () => onClick(execRes)
+    })
+  }
+
+  const closeNotification = (key: string | number, delay = 10000) => {
+    setTimeout(() => {
+      notification.close(typeof key === 'number' ? `${key}` : key)
+    }, delay)
+  }
+
+  const processCommand = async (params: CommandRequest, nextAction?: (...args: 
any) => void) => {
+    try {
+      const { id: jobId, name } = await execCommand(params)
+      if (jobId && name) {
+        progressMap.set(jobId, Object.assign(params, 
jobStageProgress.value.processing()))
+        openNotification({ jobId, clusterId: params.clusterId!, name }, 
nextAction)
+      }
+    } catch (error) {
+      console.log('error :>> ', error)
+    }
+  }
+
+  const onClick = (execRes: CommandRes) => {
+    Modal.destroyAll()
+    Modal.confirm({
+      icon: null,
+      width: '980px',
+      zIndex: 9999,
+      mask: false,
+      closable: true,
+      centered: true,
+      appContext: instance?.appContext,
+      content: () => h(JobModal, { execRes, jobInfo: progressMap })
+    })
+  }
+
+  return {
+    processCommand
+  }
+})
diff --git a/bigtop-manager-ui/src/store/service/index.ts 
b/bigtop-manager-ui/src/store/service/index.ts
index 47d45a15..3eb04458 100644
--- a/bigtop-manager-ui/src/store/service/index.ts
+++ b/bigtop-manager-ui/src/store/service/index.ts
@@ -21,7 +21,7 @@ import { defineStore, storeToRefs } from 'pinia'
 import { computed, ref } from 'vue'
 import { getService, getServiceList } from '@/api/service'
 import { useStackStore } from '@/store/stack'
-import { useInstalledStore } from '@/store/installed'
+import { InstalledMapItem, useInstalledStore } from '@/store/installed'
 import type { ServiceListParams, ServiceVO } from '@/api/service/types'
 
 export const useServiceStore = defineStore(
@@ -33,9 +33,7 @@ export const useServiceStore = defineStore(
     const total = ref(0)
     const loading = ref(false)
     const { stacks } = storeToRefs(stackStore)
-
     const serviceNames = computed(() => services.value.map((v) => v.name))
-    const serviceMap = computed(() => services.value.map((v) => 
`${v.id}-${v.name}`))
     const locateStackWithService = computed(() => {
       return stacks.value.filter((item) =>
         item.services.some((service) => service.name && 
serviceNames.value.includes(service.name))
@@ -48,7 +46,13 @@ export const useServiceStore = defineStore(
         const data = await getServiceList(clusterId, { ...filterParams, 
pageNum: 1, pageSize: 100 })
         services.value = data.content
         total.value = data.total
-        installedStore.setInstalledMapKeyOfValue(`${clusterId}`, 
serviceMap.value)
+        const serviceMap = services.value.map((v) => ({
+          serviceId: v.id,
+          serviceName: v.name,
+          serviceDisplayName: v.displayName,
+          clusterId: clusterId
+        })) as InstalledMapItem[]
+        installedStore.setInstalledMapKeyOfValue(`${clusterId}`, serviceMap)
       } catch (error) {
         console.log('error :>> ', error)
       } finally {
diff --git a/bigtop-manager-ui/src/store/stack/index.ts 
b/bigtop-manager-ui/src/store/stack/index.ts
index bee272c2..358f74ab 100644
--- a/bigtop-manager-ui/src/store/stack/index.ts
+++ b/bigtop-manager-ui/src/store/stack/index.ts
@@ -17,27 +17,65 @@
  * under the License.
  */
 
-import { ServiceVO } from '@/api/service/types'
 import { getStacks } from '@/api/stack'
-import { StackVO } from '@/api/stack/types'
 import { defineStore } from 'pinia'
 import { shallowRef } from 'vue'
+import type { ComponentVO } from '@/api/component/types'
+import type { ServiceConfig, ServiceVO } from '@/api/service/types'
+import type { StackVO } from '@/api/stack/types'
 
 export type ExpandServiceVO = ServiceVO & { order: number }
+interface StackRelationMap {
+  services: {
+    displayName: string
+    stack: string
+    components: string[]
+    configs: ServiceConfig
+  }
+  components: {
+    service: string
+    stack: string
+  } & ComponentVO
+}
 
 export const useStackStore = defineStore(
   'stack',
   () => {
     const stacks = shallowRef<StackVO[]>([])
+    const stackRelationMap = shallowRef<StackRelationMap>()
 
     const loadStacks = async () => {
       try {
         stacks.value = await getStacks()
+        stackRelationMap.value = setupStackRelationMap(stacks.value)
       } catch (error) {
         console.log('error :>> ', error)
       }
     }
 
+    const setupStackRelationMap = (stacks: StackVO[]) => {
+      const relationMap = { services: {}, components: {} } as StackRelationMap
+      for (const { stackName, services } of stacks) {
+        for (const service of services) {
+          const { name, displayName, components, configs } = service
+          relationMap.services[name!] = {
+            displayName,
+            stack: stackName,
+            components: components!.map(({ name }) => name),
+            configs
+          }
+          for (const component of components!) {
+            relationMap.components[component.name!] = {
+              ...component,
+              service: name,
+              stack: stackName
+            }
+          }
+        }
+      }
+      return relationMap
+    }
+
     const getServicesByExclude = (exclude?: string[], isOrder = true): 
ExpandServiceVO[] | ServiceVO[] => {
       const filterData = stacks.value.flatMap((stack) => 
(exclude?.includes(stack.stackName) ? [] : stack.services))
       return isOrder ? filterData.map((service, index) => ({ ...service, 
order: index })) : filterData
@@ -45,6 +83,7 @@ export const useStackStore = defineStore(
 
     return {
       stacks,
+      stackRelationMap,
       loadStacks,
       getServicesByExclude
     }
diff --git a/bigtop-manager-ui/src/styles/index.scss 
b/bigtop-manager-ui/src/styles/index.scss
index 84c3bac3..fe88f1d9 100644
--- a/bigtop-manager-ui/src/styles/index.scss
+++ b/bigtop-manager-ui/src/styles/index.scss
@@ -69,4 +69,71 @@
   width: 100%;
   display: inline-block;
   position: relative;
-}
\ No newline at end of file
+}
+
+
+.job-progress-notification{
+  &:hover{
+    background-color: $color-bg-text-hover;
+    cursor: pointer;
+  }
+  .ant-notification-notice-close{
+    top: 16px !important;
+  }
+  :where(.ant-notification-notice-content){
+ 
+    .ant-notification-notice-message,
+    .ant-notification-notice-description {
+      margin-left: 50px !important;
+      margin-bottom: 0 !important; 
+      padding-inline-end: calc(24px / 2);
+      .ant-progress-line{
+        margin-bottom: 6px !important;
+      }
+    }
+  
+    .ant-notification-notice-message{
+      font-size: 14px !important;
+      font-weight: 500 !important;
+    }
+  
+    .ant-notification-notice-icon{
+      top: 50%;
+      transform: translateY(-50%);
+      margin: auto;
+    }
+  }
+}
+
+
+.text-loading::after {
+  content: '';
+  display: inline-block;
+  animation: loading-dots 1.5s infinite;
+}
+
+@keyframes loading-dots {
+  0% {
+    content: '';
+  }
+  16% {
+    content: '.';
+  }
+  32% {
+    content: '..';
+  }
+  48% {
+    content: '...';
+  }
+  80% {
+    content: '....';
+  }
+  96% {
+    content: '.....';
+  }
+  100% {
+    content: '......';
+  }
+}
+
+

Reply via email to