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 c034eb78 BIGTOP-4394: Add host page (#193)
c034eb78 is described below

commit c034eb7830de1acc87fea1651e9d97e30cfef037
Author: Fdefined <[email protected]>
AuthorDate: Wed Mar 26 22:38:40 2025 +0800

    BIGTOP-4394: Add host page (#193)
---
 bigtop-manager-ui/src/api/hosts/index.ts           |  30 ++
 bigtop-manager-ui/src/api/hosts/types.ts           |   2 +
 bigtop-manager-ui/src/locales/en_US/common.ts      |   6 +-
 bigtop-manager-ui/src/locales/en_US/host.ts        |   1 +
 bigtop-manager-ui/src/locales/en_US/overview.ts    |  14 +-
 bigtop-manager-ui/src/locales/zh_CN/common.ts      |   6 +-
 bigtop-manager-ui/src/locales/zh_CN/host.ts        |   1 +
 bigtop-manager-ui/src/locales/zh_CN/overview.ts    |  12 +-
 .../cluster/components/host-config.vue             |   2 +-
 .../src/pages/cluster-manage/cluster/overview.vue  |  14 +-
 .../src/pages/cluster-manage/hosts/create.vue      | 103 +++++-
 .../src/pages/cluster-manage/hosts/detail.vue      | 112 +++++++
 .../src/pages/cluster-manage/hosts/index.vue       | 364 ++++++++++++++++++++-
 .../cluster-manage/hosts/install-dependencies.vue  | 355 ++++++++++++++++++++
 .../cluster-manage/{cluster => hosts}/overview.vue | 203 +++++++-----
 .../src/router/routes/modules/clusters.ts          |   9 +
 bigtop-manager-ui/src/store/cluster/index.ts       |   5 +-
 bigtop-manager-ui/src/store/installed/index.ts     |   1 +
 bigtop-manager-ui/src/store/menu/index.ts          |  35 +-
 19 files changed, 1136 insertions(+), 139 deletions(-)

diff --git a/bigtop-manager-ui/src/api/hosts/index.ts 
b/bigtop-manager-ui/src/api/hosts/index.ts
index 0f401364..80c7669c 100644
--- a/bigtop-manager-ui/src/api/hosts/index.ts
+++ b/bigtop-manager-ui/src/api/hosts/index.ts
@@ -19,6 +19,7 @@
 
 import request from '@/api/request.ts'
 import type { HostListParams, HostParams, HostVO, HostVOList, 
InstalledStatusVO } from '@/api/hosts/types.ts'
+import type { ComponentVO } from '@/api/component/types.ts'
 
 export const getHosts = (params?: HostListParams): Promise<HostVOList> => {
   return request({
@@ -27,6 +28,12 @@ export const getHosts = (params?: HostListParams): 
Promise<HostVOList> => {
     params
   })
 }
+export const getHost = (pathParams: { id: number }): Promise<HostVO> => {
+  return request({
+    method: 'get',
+    url: `/hosts/${pathParams.id}`
+  })
+}
 
 export const addHost = (data: HostParams): Promise<HostVO> => {
   return request({
@@ -50,3 +57,26 @@ export const getInstalledStatus = (): 
Promise<InstalledStatusVO[]> => {
     url: '/hosts/installed-status'
   })
 }
+
+export const updateHost = (data: HostParams): Promise<HostVO[]> => {
+  return request({
+    method: 'put',
+    url: `/hosts/${data.id}`,
+    data
+  })
+}
+
+export const removeHost = (data: { ids: number[] }): Promise<boolean> => {
+  return request({
+    method: 'delete',
+    url: '/hosts/batch',
+    data
+  })
+}
+
+export const getComponentsByHost = (pathParams: { id: number }): 
Promise<ComponentVO[]> => {
+  return request({
+    method: 'get',
+    url: `/hosts/${pathParams.id}/components`
+  })
+}
diff --git a/bigtop-manager-ui/src/api/hosts/types.ts 
b/bigtop-manager-ui/src/api/hosts/types.ts
index dfbd53e6..2b3443a8 100644
--- a/bigtop-manager-ui/src/api/hosts/types.ts
+++ b/bigtop-manager-ui/src/api/hosts/types.ts
@@ -21,6 +21,8 @@ import type { PageVO } from '@/api/types'
 
 export type HostVOList = PageVO<HostVO>
 
+export type HostStatusType = 1 | 2 | 3
+
 /**
  * HostVO
  */
diff --git a/bigtop-manager-ui/src/locales/en_US/common.ts 
b/bigtop-manager-ui/src/locales/en_US/common.ts
index 61040ccf..28441444 100644
--- a/bigtop-manager-ui/src/locales/en_US/common.ts
+++ b/bigtop-manager-ui/src/locales/en_US/common.ts
@@ -57,6 +57,7 @@ export default {
   host: 'Host',
   service: 'Service',
   stack: 'Stack',
+  component: 'Component',
   stage: 'Stage',
   search: 'Search',
   reset: 'Reset',
@@ -90,6 +91,8 @@ export default {
   select_error: 'Please select {0}',
   add_error: 'Please add {0}',
   delete_msg: 'Are you sure you want to delete this?',
+  delete_msgs: 'Are you sure you want to delete these?',
+  delete_empty: 'Please select the records you want to delete.',
   total: 'Total {0} items',
   note: 'Note: Changes to the source address will take effect for all 
clusters.',
   upload_file: 'Upload file',
@@ -108,5 +111,6 @@ export default {
   more_operations: 'More Operations',
   ok: 'Ok',
   yes: 'Yes',
-  no: 'No'
+  no: 'No',
+  all: 'All'
 }
diff --git a/bigtop-manager-ui/src/locales/en_US/host.ts 
b/bigtop-manager-ui/src/locales/en_US/host.ts
index 22ae699b..7bfd805d 100644
--- a/bigtop-manager-ui/src/locales/en_US/host.ts
+++ b/bigtop-manager-ui/src/locales/en_US/host.ts
@@ -35,6 +35,7 @@ export default {
   key_auth: 'Key',
   ip_address: 'IP Address',
   component_count: 'Component Count',
+  component_count_quantifier: 'Components',
   no_auth: 'No Authentication',
   agent_path: 'Agent Path',
   ssh_port: 'SSH Port',
diff --git a/bigtop-manager-ui/src/locales/en_US/overview.ts 
b/bigtop-manager-ui/src/locales/en_US/overview.ts
index fde5bedc..6c2dd09c 100644
--- a/bigtop-manager-ui/src/locales/en_US/overview.ts
+++ b/bigtop-manager-ui/src/locales/en_US/overview.ts
@@ -27,12 +27,22 @@ export default {
   service_count: 'Service Count',
   memory: 'Memory',
   chart: 'Chart',
-  core_count: 'Processor Count',
+  core_count: 'Core Count',
   disk_size: 'Disk Size',
   creator: 'Creator',
   memory_usage: 'Memory Usage',
   cpu_usage: 'Cpu Usage',
   unit_host: 'Hosts',
   unit_service: 'Services',
-  unit_processor: 'Processors'
+  unit_core: 'Cores',
+  unit_component: 'Components',
+  component_info: 'Component Information',
+  host_status: 'Status',
+  host_name: 'Name',
+  host_desc: 'Description',
+  component_count: 'Component Count',
+  os: 'OS',
+  ip_v4: 'IPv4',
+  ip_v6: 'IPv6',
+  arch: 'Architecture'
 }
diff --git a/bigtop-manager-ui/src/locales/zh_CN/common.ts 
b/bigtop-manager-ui/src/locales/zh_CN/common.ts
index cc7880f4..3493fe8c 100644
--- a/bigtop-manager-ui/src/locales/zh_CN/common.ts
+++ b/bigtop-manager-ui/src/locales/zh_CN/common.ts
@@ -57,6 +57,7 @@ export default {
   host: '主机',
   service: '服务',
   stack: '组件栈',
+  component: '组件',
   stage: '阶段',
   search: '搜索',
   required: '需重启',
@@ -89,6 +90,8 @@ export default {
   select_error: '请选择{0}',
   add_error: '请添加{0}',
   delete_msg: '确认要删除该记录吗?',
+  delete_msgs: '确认要删除这些记录吗?',
+  delete_empty: '请选择需要删除的记录',
   total: '共{0}条数据',
   note: '注: 源地址的改动对所有集群生效。',
   upload_file: '上传文件',
@@ -108,5 +111,6 @@ export default {
   more_operations: '其他操作',
   ok: '确认',
   yes: '是',
-  no: '否'
+  no: '否',
+  all: '全部'
 }
diff --git a/bigtop-manager-ui/src/locales/zh_CN/host.ts 
b/bigtop-manager-ui/src/locales/zh_CN/host.ts
index 51b4d662..88ffefe3 100644
--- a/bigtop-manager-ui/src/locales/zh_CN/host.ts
+++ b/bigtop-manager-ui/src/locales/zh_CN/host.ts
@@ -35,6 +35,7 @@ export default {
   key_auth: '密钥',
   ip_address: 'IP 地址',
   component_count: '组件数',
+  component_count_quantifier: '个组件',
   no_auth: '无认证',
   agent_path: 'Agent路径',
   ssh_port: 'SSH端口',
diff --git a/bigtop-manager-ui/src/locales/zh_CN/overview.ts 
b/bigtop-manager-ui/src/locales/zh_CN/overview.ts
index ced7f376..c1be8803 100644
--- a/bigtop-manager-ui/src/locales/zh_CN/overview.ts
+++ b/bigtop-manager-ui/src/locales/zh_CN/overview.ts
@@ -34,5 +34,15 @@ export default {
   cpu_usage: 'CPU使用率',
   unit_host: '个主机',
   unit_service: '个服务',
-  unit_processor: '个核心'
+  unit_core: '个核心',
+  unit_component: '个组件',
+  component_info: '组件信息',
+  host_status: '主机状态',
+  host_name: '主机名',
+  host_desc: '主机备注',
+  component_count: '组件数',
+  os: '操作系统',
+  ip_v4: 'IPv4',
+  ip_v6: 'IPv6',
+  arch: '架构'
 }
diff --git 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/components/host-config.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/components/host-config.vue
index a04074c3..d0ea468e 100644
--- 
a/bigtop-manager-ui/src/pages/cluster-manage/cluster/components/host-config.vue
+++ 
b/bigtop-manager-ui/src/pages/cluster-manage/cluster/components/host-config.vue
@@ -113,7 +113,7 @@
     }
   )
 
-  const isContain = (source: string, target: string) => {
+  const isContain = (source: string, target: string): boolean => {
     return source.toString().toLowerCase().includes(target.toLowerCase())
   }
 
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 131ff884..714eb11e 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
@@ -103,7 +103,7 @@
     return {
       totalHost: t('overview.unit_host'),
       totalService: t('overview.unit_service'),
-      totalProcessor: t('overview.unit_processor')
+      totalProcessor: t('overview.unit_core')
     }
   })
   const detailKeys = computed(() => Object.keys(baseConfig.value) as (keyof 
ClusterVO)[])
@@ -222,7 +222,7 @@
               </div>
             </template>
             <div v-for="service in servicesFromCurrentCluster(stack)" 
:key="service.id" class="service-item">
-              <a-avatar v-if="service.name" 
:src="usePngImage(service.name.toLowerCase())" :size="16" />
+              <a-avatar v-if="service.name" 
:src="usePngImage(service.name.toLowerCase())" :size="22" />
               <a-typography-text :content="service.displayName" />
               <a-dropdown :trigger="['click']">
                 <a-button type="text" shape="circle" size="small">
@@ -291,10 +291,12 @@
 <style lang="scss" scoped>
   :deep(.ant-avatar) {
     border-radius: 4px;
+
     img {
       object-fit: contain !important;
     }
   }
+
   .box {
     &-title {
       @include flexbox($justify: space-between);
@@ -329,6 +331,7 @@
     &:hover {
       color: $color-primary-text-hover;
     }
+
     &-activated {
       color: $color-primary-text;
     }
@@ -340,7 +343,7 @@
     gap: $space-md;
     align-items: center;
     padding: 12px 16px;
-    border-bottom: 1px solid #f0f0f0;
+    border-top: 1px solid #f0f0f0;
   }
 
   .chart-item-wrp {
@@ -367,9 +370,11 @@
     :deep(.ant-descriptions-view) {
       overflow: hidden;
       border-color: $color-border;
+
       .ant-descriptions-item-label {
         padding: 0;
       }
+
       .ant-descriptions-item-content {
         padding: 0;
       }
@@ -378,6 +383,7 @@
     .desc-sub-item-wrp {
       @include flexbox($gap: $space-md);
       padding: $space-md;
+
       :deep(.ant-tag) {
         width: fit-content;
         @include flexbox($align: center, $gap: 4px);
@@ -388,8 +394,10 @@
       display: grid;
       grid-template-columns: max-content 1fr;
       gap: 16px;
+
       &-desc {
         display: contents;
+
         &-column {
           text-align: left;
         }
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/hosts/create.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/create.vue
index 5354a8ae..a1e3d01a 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/hosts/create.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/create.vue
@@ -18,7 +18,7 @@
 -->
 
 <script setup lang="ts">
-  import { computed, h, ref, watch } from 'vue'
+  import { computed, h, nextTick, ref, watch } from 'vue'
   import { useI18n } from 'vue-i18n'
   import { FormItemState } from '@/components/common/auto-form/types'
   import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
@@ -28,9 +28,11 @@
   import { storeToRefs } from 'pinia'
   import { uploadFile } from '@/api/upload-file'
   import { Rule } from 'ant-design-vue/es/form'
-  import { HostReq } from '@/api/command/types'
+  import { updateHost } from '@/api/hosts'
+  import { useClusterStore } from '@/store/cluster'
   import type { UploadProps } from 'ant-design-vue'
-  import type { HostParams } from '@/api/hosts/types'
+  import type { HostReq } from '@/api/command/types'
+  import type { HostParams, HostVO } from '@/api/hosts/types'
 
   enum Mode {
     EDIT = 'cluster.edit_host',
@@ -38,13 +40,20 @@
   }
 
   interface Emits {
-    (event: 'onOk', type: keyof typeof Mode, value: HostReq): void
+    (event: 'onOk', type: keyof typeof Mode, value: HostReq | HostVO): void
   }
 
+  const props = withDefaults(defineProps<{ isPublic?: boolean; apiEditCaller?: 
boolean }>(), {
+    isPublic: false,
+    apiEditCaller: false
+  })
+
   const { t } = useI18n()
   const emits = defineEmits<Emits>()
   const localeStore = useLocaleStore()
+  const clusterStore = useClusterStore()
   const open = ref(false)
+  const loading = ref(false)
   const mode = ref<keyof typeof Mode>('ADD')
   const hiddenItems = ref<string[]>([])
   const autoFormRef = ref<Comp.AutoFormInstance | null>(null)
@@ -193,6 +202,34 @@
       }
     }
   ])
+
+  const formItemsOfPublicHost = computed((): FormItemState[] => [
+    {
+      type: 'select',
+      field: 'clusterId',
+      defaultValue: '',
+      fieldMap: {
+        label: 'displayName',
+        value: 'clusterId'
+      },
+      formItemProps: {
+        name: 'clusterId',
+        label: t('common.cluster'),
+        rules: [
+          {
+            required: true,
+            message: t('common.select_error', 
[`${t('common.cluster')}`.toLowerCase()]),
+            trigger: 'blur'
+          }
+        ]
+      },
+      controlProps: {
+        disabled: isEdit.value,
+        placeholder: t('common.select_error', 
[`${t('common.cluster')}`.toLowerCase()])
+      }
+    }
+  ])
+
   const formItems = computed((): FormItemState[] => [
     {
       type: 'input',
@@ -230,7 +267,6 @@
         ]
       }
     },
-
     {
       type: mode.value == 'ADD' ? 'textarea' : 'input',
       field: 'hostname',
@@ -298,13 +334,13 @@
     if (formValue.value.authType === '1') {
       const data = [...formItems.value]
       data.splice(2, 0, ...formItemsForSshPassword.value)
-      return data
+      return props.isPublic ? [...formItemsOfPublicHost.value, ...data] : data
     } else if (formValue.value.authType === '2') {
       const data = [...formItems.value]
       data.splice(2, 0, ...formItemsForSshKeyPassword.value)
-      return data
+      return props.isPublic ? [...formItemsOfPublicHost.value, ...data] : data
     }
-    return [...formItems.value]
+    return props.isPublic ? [...formItemsOfPublicHost.value, 
...formItems.value] : [...formItems.value]
   })
 
   watch(
@@ -327,13 +363,40 @@
     open.value = true
     mode.value = type
     if (payload) {
-      formValue.value = Object.assign(formValue.value, { ...payload })
+      formValue.value = Object.assign(formValue.value, {
+        ...payload,
+        authType: `${payload.authType}`
+      })
     } else {
       Object.assign(formValue.value, {
         authType: '1',
         inputType: '1'
       })
     }
+
+    props.isPublic && (await getClusterSelectOptions())
+  }
+
+  const getClusterSelectOptions = async () => {
+    await nextTick()
+    const formatClusters = clusterStore.clusters.map((v) => ({ ...v, 
clusterId: v.id }))
+    autoFormRef.value?.setOptions('clusterId', formatClusters)
+  }
+
+  const editHost = async (hostConfig: HostReq) => {
+    try {
+      const data = await updateHost(hostConfig)
+      if (data) {
+        loading.value = false
+        message.success(t('common.update_success'))
+        emits('onOk', mode.value, formValue.value)
+        handleCancel()
+      }
+    } catch (error) {
+      console.log('error :>> ', error)
+    } finally {
+      loading.value = false
+    }
   }
 
   const handleOk = async () => {
@@ -347,8 +410,13 @@
           handleCancel()
         }
       } else {
-        emits('onOk', mode.value, formValue.value)
-        handleCancel()
+        if (props.apiEditCaller) {
+          loading.value = true
+          await editHost(formValue.value)
+        } else {
+          emits('onOk', mode.value, formValue.value)
+          handleCancel()
+        }
       }
     } catch (e) {
       console.log(e)
@@ -434,12 +502,12 @@
   }
 
   const beforeUpload: UploadProps['beforeUpload'] = (file) => {
-    const isText = file.type === 'text/plain'
+    // const isText = file.type === 'text/plain'
     const checkLimitSize = file.size / 1024 <= 10
-    if (!isText) {
-      message.error(t('common.file_type_error'))
-      return false
-    }
+    // if (!isText) {
+    //   message.error(t('common.file_type_error'))
+    //   return false
+    // }
     if (!checkLimitSize) {
       message.error(t('common.file_size_error'))
       return false
@@ -476,6 +544,7 @@
       :width="600"
       :title="Mode[mode] && $t(Mode[mode])"
       :mask-closable="false"
+      :confirm-loading="loading"
       :centered="true"
       :destroy-on-close="true"
       @ok="handleOk"
@@ -504,7 +573,7 @@
                 {{ $t('common.upload_file') }}
               </a-button>
             </a-upload>
-            <span class="filename">{{ fileName }}</span>
+            <span class="filename">{{ fileName ? fileName : mode === 'EDIT' ? 
formValue[item.field] : '' }}</span>
           </a-form-item>
         </template>
       </auto-form>
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue
new file mode 100644
index 00000000..84d221e9
--- /dev/null
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/detail.vue
@@ -0,0 +1,112 @@
+<!--
+  ~ 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
+  ~
+  ~   http://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, onMounted, ref, shallowRef } from 'vue'
+  import { useI18n } from 'vue-i18n'
+  import { useRoute } from 'vue-router'
+  import { getHost } from '@/api/hosts'
+  import { CommonStatus, CommonStatusTexts } from '@/enums/state'
+  import Overview from './overview.vue'
+  import type { GroupItem } from '@/components/common/button-group/types'
+  import type { HostStatusType, HostVO } from '@/api/hosts/types.ts'
+
+  const { t } = useI18n()
+  const route = useRoute()
+  const hostInfo = shallowRef<HostVO>({})
+  const loading = ref(false)
+  const statusColors = shallowRef<Record<HostStatusType, keyof typeof 
CommonStatusTexts>>({
+    1: 'healthy',
+    2: 'unhealthy',
+    3: 'unknown'
+  })
+  const actionGroup = computed<GroupItem[]>(() => [
+    {
+      shape: 'default',
+      type: 'default',
+      text: t('common.more_operations'),
+      dropdownMenu: [
+        {
+          action: 'Start',
+          text: t('common.start', [t('common.all')])
+        },
+        {
+          action: 'Restart',
+          text: t('common.restart', [t('common.all')])
+        },
+        {
+          action: 'Stop',
+          text: t('common.stop', [t('common.all')])
+        }
+      ],
+      dropdownMenuClickEvent: (info) => dropdownMenuClick && 
dropdownMenuClick(info)
+    }
+  ])
+
+  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 setupHostInfo = async () => {
+    try {
+      loading.value = true
+      const hostId = route.query.hostId
+      const data = await getHost({ id: Number(hostId) })
+      hostInfo.value = {
+        ...data
+      }
+    } catch (error) {
+      console.log(error)
+    } finally {
+      loading.value = false
+    }
+  }
+
+  onMounted(() => {
+    setupHostInfo()
+  })
+</script>
+
+<template>
+  <a-spin :spinning="loading">
+    <header-card
+      :title="hostInfo.hostname"
+      avatar="host"
+      :status="CommonStatus[statusColors[hostInfo.status as HostStatusType]]"
+      :desc="hostInfo.desc"
+      :action-groups="actionGroup"
+    />
+    <main-card>
+      <overview :host-info="hostInfo" />
+    </main-card>
+  </a-spin>
+</template>
+
+<style lang="scss" scoped></style>
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 8150b621..caf4eda8 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/hosts/index.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/index.vue
@@ -18,18 +18,372 @@
 -->
 
 <script setup lang="ts">
+  import { message, Modal, TableColumnType, TableProps } from 'ant-design-vue'
+  import { computed, onMounted, onUnmounted, reactive, ref, shallowRef } from 
'vue'
   import { useRouter } from 'vue-router'
+  import { useI18n } from 'vue-i18n'
+  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'
+  import type { HostVO } from '@/api/hosts/types'
+
+  const POLLING_INTERVAL = 30000
+  type Key = string | number
+
+  interface TableState {
+    selectedRowKeys: Key[]
+    searchText: string
+    searchedColumn: string
+  }
+
+  const { t } = useI18n()
   const router = useRouter()
+  const clusterStore = useClusterStore()
+  const searchInputRef = ref()
+  const pollingIntervalId = ref<any>(null)
+  const hostCreateRef = ref<InstanceType<typeof HostCreate> | null>(null)
+  const installRef = ref<InstanceType<typeof InstallDependencies> | null>(null)
+  const hostStatus = shallowRef(['INSTALLING', 'SUCCESS', 'FAILED', 'UNKNOWN'])
+  const state = reactive<TableState>({
+    searchText: '',
+    searchedColumn: '',
+    selectedRowKeys: []
+  })
+  const filtersOfClusterDisplayName = computed(() =>
+    clusterStore.clusters.map((v) => ({
+      text: v.displayName || v.name,
+      value: v.id!
+    }))
+  )
+  const columns = computed((): TableColumnType<HostVO>[] => [
+    {
+      title: t('host.hostname'),
+      dataIndex: 'hostname',
+      key: 'hostname',
+      ellipsis: true,
+      customFilterDropdown: true,
+      onFilterDropdownOpenChange: (visible) => 
onFilterDropdownOpenChange(visible)
+    },
+    {
+      title: t('common.cluster'),
+      key: 'clusterDisplayName',
+      dataIndex: 'clusterDisplayName',
+      ellipsis: true,
+      filterMultiple: false,
+      filters: filtersOfClusterDisplayName.value
+    },
+    {
+      title: t('host.ip_address'),
+      dataIndex: 'ipv4',
+      key: 'ipv4',
+      ellipsis: true,
+      customFilterDropdown: true,
+      onFilterDropdownOpenChange: (visible) => 
onFilterDropdownOpenChange(visible)
+    },
+    {
+      key: 'os',
+      title: t('common.os'),
+      dataIndex: 'os',
+      ellipsis: true
+    },
+    {
+      title: t('common.arch'),
+      dataIndex: 'arch',
+      ellipsis: true
+    },
+    {
+      title: t('host.component_count'),
+      dataIndex: 'componentNum',
+      key: 'componentNum',
+      width: '180px',
+      ellipsis: true
+    },
+    {
+      title: t('common.status'),
+      dataIndex: 'status',
+      key: 'status',
+      width: '260px',
+      ellipsis: true,
+      filterMultiple: false,
+      filters: [
+        {
+          text: t('common.success'),
+          value: 1
+        },
+        {
+          text: t('common.failed'),
+          value: 2
+        },
+        {
+          text: t('common.unknown'),
+          value: 3
+        }
+      ]
+    },
+    {
+      title: t('common.action'),
+      key: 'operation',
+      width: '160px',
+      fixed: 'right'
+    }
+  ])
+
+  const operations = computed((): GroupItem[] => [
+    {
+      text: 'edit',
+      clickEvent: (_item, args) => editHost(args)
+    },
+    {
+      text: 'remove',
+      danger: true,
+      clickEvent: (_item, args) => deleteHost([args.id])
+    }
+  ])
+
+  const { loading, dataSource, filtersParams, paginationProps, onChange } = 
useBaseTable<HostVO>({
+    columns: columns.value,
+    rows: []
+  })
+
+  const handleSearch = (selectedKeys: Key[], confirm: (param?: 
FilterConfirmProps) => void, dataIndex: string) => {
+    confirm()
+    Object.assign(state, {
+      searchText: selectedKeys[0] as string,
+      searchedColumn: dataIndex
+    })
+    getHostList(true)
+  }
+
+  const handleReset = (clearFilters: (param?: FilterResetProps) => void) => {
+    clearFilters({ confirm: true })
+    state.searchText = ''
+    getHostList(true)
+  }
+
+  const getHostList = async (isFirstCall = false) => {
+    if (!paginationProps.value) {
+      loading.value = false
+      return
+    }
+    try {
+      if (isFirstCall) {
+        loading.value = true
+        paginationProps.value.current = 1
+      }
+      const res = await hostApi.getHosts({ ...filtersParams.value, clusterId: 
0 })
+      dataSource.value = res.content
+      paginationProps.value.total = res.total
+      loading.value = false
+    } catch (error) {
+      console.log('error :>> ', error)
+      stopPolling()
+    } finally {
+      loading.value = false
+    }
+  }
+
+  const onSelectChange: TableRowSelection['onChange'] = (selectedRowKeys) => {
+    state.selectedRowKeys = selectedRowKeys
+  }
+
+  const onFilterDropdownOpenChange = (visible: boolean) => {
+    if (visible) {
+      setTimeout(() => {
+        searchInputRef.value.focus()
+      }, 100)
+    }
+  }
+
   const addHost = () => {
-    router.push({ name: 'HostCreate' })
+    hostCreateRef.value?.handleOpen('ADD')
   }
+
+  const bulkRemove = () => {
+    if (state.selectedRowKeys.length === 0) {
+      message.error(t('common.delete_empty'))
+      return
+    }
+    deleteHost(state.selectedRowKeys as number[])
+  }
+
+  const editHost = (row: HostVO) => {
+    const cluster = clusterStore.clusters.find((v) => v.name === 
row.clusterName)
+    const formatHost = { ...row, displayName: row.clusterDisplayName, 
clusterId: cluster?.id }
+    hostCreateRef.value?.handleOpen('EDIT', formatHost)
+  }
+
+  const deleteHost = (ids: number[]) => {
+    Modal.confirm({
+      title: ids.length > 1 ? t('common.delete_msgs') : t('common.delete_msg'),
+      async onOk() {
+        try {
+          const data = await hostApi.removeHost({ ids })
+          if (data) {
+            message.success(t('common.delete_success'))
+            await getHostList(true)
+          }
+        } catch (error) {
+          console.log('error :>> ', error)
+        }
+      }
+    })
+  }
+
+  const afterSetupHostConfig = async (type: 'ADD' | 'EDIT', item: HostReq) => {
+    type === 'ADD' ? installRef.value?.handleOpen(item) : await 
getHostList(true)
+  }
+
+  onMounted(() => {
+    startPolling()
+  })
+
+  const startPolling = () => {
+    getHostList(true)
+    pollingIntervalId.value = setInterval(() => {
+      getHostList()
+    }, POLLING_INTERVAL)
+  }
+
+  const stopPolling = () => {
+    if (pollingIntervalId.value) {
+      clearInterval(pollingIntervalId.value)
+      pollingIntervalId.value = null
+    }
+  }
+
+  const tableChange: TableProps['onChange'] = (pagination, filters, ...args) 
=> {
+    onChange(pagination, filters, ...args)
+    getHostList(true)
+  }
+
+  const viewHostDetail = (row: HostVO) => {
+    router.push({ name: 'HostDetail', query: { hostId: row.id } })
+  }
+
+  onUnmounted(() => {
+    stopPolling()
+  })
 </script>
 
 <template>
-  <div>
-    <a-button @click="addHost">create host</a-button>
-    <div>host list</div>
+  <div class="host-list">
+    <div class="title">{{ $t('host.host_list') }}</div>
+    <a-space :size="16">
+      <a-button type="primary" @click="addHost">{{ $t('cluster.add_host') 
}}</a-button>
+      <a-button type="primary" danger @click="bulkRemove">{{ 
$t('common.bulk_remove') }}</a-button>
+    </a-space>
+    <a-table
+      :loading="loading"
+      row-key="id"
+      :data-source="dataSource"
+      :columns="columns"
+      :pagination="paginationProps"
+      :scroll="{ x: 1300, y: 1000 }"
+      :row-selection="{ selectedRowKeys: state.selectedRowKeys, onChange: 
onSelectChange }"
+      @change="tableChange"
+    >
+      <template #customFilterDropdown="{ setSelectedKeys, selectedKeys, 
confirm, clearFilters, column }">
+        <div class="search">
+          <a-input
+            ref="searchInputRef"
+            :placeholder="$t('common.enter_error', [column.title])"
+            :value="selectedKeys[0]"
+            @change="(e: any) => setSelectedKeys(e.target?.value ? 
[e.target?.value] : [])"
+            @press-enter="handleSearch(selectedKeys, confirm, 
column.dataIndex)"
+          />
+          <div class="search-option">
+            <a-button size="small" @click="handleReset(clearFilters)">
+              {{ $t('common.reset') }}
+            </a-button>
+            <a-button type="primary" size="small" 
@click="handleSearch(selectedKeys, confirm, column.dataIndex)">
+              {{ $t('common.search') }}
+            </a-button>
+          </div>
+        </div>
+      </template>
+      <template #customFilterIcon="{ filtered, column }">
+        <svg-icon
+          v-if="!['status', 'clusterDisplayName'].includes(column.key)"
+          :name="filtered ? 'search_activated' : 'search'"
+        />
+        <svg-icon v-else :name="filtered ? 'filter_activated' : 'filter'" />
+      </template>
+      <template #bodyCell="{ record, column }">
+        <template v-if="column.key === 'hostname'">
+          <a-typography-link underlin>
+            <span :title="record.hostname" @click="viewHostDetail(record)">{{ 
record.hostname }}</span>
+          </a-typography-link>
+        </template>
+        <template v-if="column.key === 'componentNum'">
+          <span :title="record.componentNum">
+            {{ `${record.componentNum} 
${$t('host.component_count_quantifier')}` }}
+          </span>
+        </template>
+        <template v-if="column.key === 'status'">
+          <svg-icon style="margin-left: 0" 
:name="hostStatus[record.status].toLowerCase()" />
+          <span>{{ $t(`common.${hostStatus[record.status].toLowerCase()}`) 
}}</span>
+        </template>
+        <template v-if="column.key === 'operation'">
+          <button-group
+            i18n="common"
+            :text-compact="true"
+            :space="24"
+            :groups="operations"
+            :args="record"
+            group-shape="default"
+            group-type="link"
+          ></button-group>
+        </template>
+      </template>
+    </a-table>
+    <host-create ref="hostCreateRef" :api-edit-caller="true" :is-public="true" 
@on-ok="afterSetupHostConfig" />
+    <install-dependencies ref="installRef" 
@on-install-success="getHostList(true)" />
   </div>
 </template>
 
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+  .host-list {
+    width: 100%;
+    height: 100vh;
+    overflow: auto;
+    background-color: #fff;
+    padding: 16px;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+
+    .title {
+      font-size: 16px;
+      font-weight: 500;
+      line-height: 24px;
+    }
+  }
+
+  .search {
+    display: grid;
+    gap: $space-sm;
+    padding: $space-sm;
+
+    &-option {
+      width: 100%;
+      display: grid;
+      gap: $space-sm;
+      grid-template-columns: 1fr 1fr;
+
+      button {
+        width: 100%;
+      }
+    }
+  }
+
+  .highlight {
+    background-color: rgb(255, 192, 105);
+    padding: 0px;
+  }
+</style>
diff --git 
a/bigtop-manager-ui/src/pages/cluster-manage/hosts/install-dependencies.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/install-dependencies.vue
new file mode 100644
index 00000000..a55797ac
--- /dev/null
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/install-dependencies.vue
@@ -0,0 +1,355 @@
+<!--
+  ~ 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
+  ~
+  ~   http://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 { message, TableColumnType } from 'ant-design-vue'
+  import { storeToRefs } from 'pinia'
+  import { computed, reactive, ref, shallowRef } from 'vue'
+  import { useI18n } from 'vue-i18n'
+  import { getInstalledStatus, installDependencies } from '@/api/hosts'
+  import { useClusterStore } from '@/store/cluster'
+  import { generateRandomId } from '@/utils/tools'
+  import { execCommand } from '@/api/command'
+  import HostCreate from '@/pages/cluster-manage/hosts/create.vue'
+  import useBaseTable from '@/composables/use-base-table'
+  import { type InstalledStatusVO, Status } from '@/api/hosts/types'
+  import type { FilterConfirmProps, FilterResetProps } from 
'ant-design-vue/es/table/interface'
+  import type { GroupItem } from '@/components/common/button-group/types'
+  import type { CommandRequest, HostReq } from '@/api/command/types'
+
+  type Key = string | number
+
+  interface TableState {
+    selectedRowKeys: Key[]
+    searchText: string
+    searchedColumn: string
+  }
+
+  interface Emits {
+    (event: 'onInstallSuccess'): void
+  }
+
+  const { t } = useI18n()
+  const emits = defineEmits<Emits>()
+  const clusterStore = useClusterStore()
+  const { clusters } = storeToRefs(clusterStore)
+  const installing = ref(false)
+  const open = ref(false)
+  const searchInputRef = ref()
+  const hostCreateRef = ref<InstanceType<typeof HostCreate> | null>(null)
+  const installStatus = shallowRef<InstalledStatusVO[]>([])
+  const state = reactive<TableState>({
+    searchText: '',
+    searchedColumn: '',
+    selectedRowKeys: []
+  })
+  const allInstallSuccess = computed(() => dataSource.value.every((v) => 
v.status === Status.Success))
+  const clusterNameMap = computed(() => new Map(clusters.value.map((v) => 
[v.id, v])))
+  const columns = computed((): TableColumnType<HostReq & { status: string }>[] 
=> [
+    {
+      title: t('host.hostname'),
+      dataIndex: 'hostname',
+      key: 'hostname',
+      ellipsis: true,
+      customFilterDropdown: true,
+      onFilter: (value, record) => isContain(record.hostname, value as string)
+    },
+    {
+      title: t('common.cluster'),
+      key: 'clusterId',
+      dataIndex: 'clusterId',
+      ellipsis: true
+    },
+    {
+      title: t('common.status'),
+      dataIndex: 'status',
+      key: 'status',
+      ellipsis: true,
+      filterMultiple: false,
+      onFilter: (value, record: HostReq & { status: string }) => 
record.status.includes(value as string),
+      filters: [
+        {
+          text: t('common.unknown'),
+          value: Status.Unknown
+        },
+        {
+          text: t('common.success'),
+          value: Status.Success
+        },
+        {
+          text: t('common.failed'),
+          value: Status.Failed
+        }
+      ]
+    },
+    {
+      title: t('common.action'),
+      width: '180px',
+      key: 'operation',
+      fixed: 'right'
+    }
+  ])
+
+  const operations = computed((): GroupItem[] => [
+    {
+      text: 'edit',
+      disabled: installing.value,
+      clickEvent: (_item, args) => editPreAddedHost(args)
+    },
+    {
+      text: 'remove',
+      danger: true,
+      disabled: installing.value,
+      clickEvent: (_item, args) => deletePreAddedHost(args)
+    }
+  ])
+
+  const { loading, dataSource, paginationProps, onChange, resetState } = 
useBaseTable<HostReq>({
+    columns: columns.value,
+    rows: []
+  })
+
+  const isContain = (source: string, target: string): boolean => {
+    return source.toString().toLowerCase().includes(target.toLowerCase())
+  }
+
+  const splitHostFormToHostList = (hostForm: HostReq) => {
+    dataSource.value = hostForm.hostnames?.map((v) => ({
+      ...hostForm,
+      hostname: v,
+      hostnames: [v],
+      status: 'UNKNOWN',
+      key: generateRandomId()
+    })) as HostReq[]
+  }
+
+  const setHostStatusToInstalling = () => {
+    return dataSource.value.map((item: HostReq) => ({
+      ...item,
+      status: Status.Installing
+    }))
+  }
+
+  const startInstallDependencies = async () => {
+    if (dataSource.value.length == 0) {
+      message.error(t('host.uninstallable'))
+      return
+    }
+    try {
+      installing.value = true
+      const data = await installDependencies((dataSource.value = 
setHostStatusToInstalling()))
+      data && pollUntilInstalled()
+    } catch (error) {
+      installing.value = false
+      console.log('error :>> ', error)
+    }
+  }
+
+  const pollUntilInstalled = (interval: number = 1000): void => {
+    let isInitialized = false
+    const intervalId = setInterval(async () => {
+      if (!isInitialized) {
+        dataSource.value = setHostStatusToInstalling()
+        isInitialized = true
+      }
+      const result = await recordInstalledStatus()
+      if (result) {
+        installing.value = false
+        clearInterval(intervalId)
+      }
+    }, interval)
+  }
+
+  const recordInstalledStatus = async () => {
+    try {
+      const data = await getInstalledStatus()
+      installStatus.value = data
+      dataSource.value = mergeByHostname(dataSource.value, data)
+      return data.every((item) => item.status != Status.Installing)
+    } catch (error) {
+      console.log('error :>> ', error)
+    }
+  }
+
+  const mergeByHostname = (arr1: any[], arr2: any[]): any[] => {
+    const mergedMap = new Map<string, any>()
+    for (const item of arr1) {
+      mergedMap.set(item.hostname, { ...item })
+    }
+    for (const item of arr2) {
+      if (mergedMap.has(item.hostname)) {
+        mergedMap.set(item.hostname, { ...mergedMap.get(item.hostname), 
...item })
+      } else {
+        mergedMap.set(item.hostname, { ...item })
+      }
+    }
+    return Array.from(mergedMap.values())
+  }
+
+  const startAddHosts = async () => {
+    const data = {
+      command: 'Add',
+      commandLevel: 'host',
+      hostCommands: dataSource.value
+    } as CommandRequest
+    installing.value = true
+    try {
+      await execCommand(data)
+      emits('onInstallSuccess')
+      handleCancel()
+    } catch (error) {
+      console.log('error :>> ', error)
+    } finally {
+      installing.value = false
+    }
+  }
+
+  const editPreAddedHost = (row: HostReq) => {
+    hostCreateRef.value?.handleOpen('EDIT', row)
+  }
+
+  const deletePreAddedHost = (row: HostReq) => {
+    dataSource.value = dataSource.value?.filter((v) => row.key !== v.key)
+  }
+
+  const handleInstalled = () => {
+    allInstallSuccess.value ? startAddHosts() : startInstallDependencies()
+  }
+
+  const handleOpen = (payLoad: HostReq) => {
+    open.value = true
+    splitHostFormToHostList(payLoad)
+  }
+
+  const handleCancel = () => {
+    open.value = false
+    resetState()
+  }
+
+  const handleSearch = (selectedKeys: Key[], confirm: (param?: 
FilterConfirmProps) => void, dataIndex: string) => {
+    confirm()
+    Object.assign(state, {
+      searchText: selectedKeys[0] as string,
+      searchedColumn: dataIndex
+    })
+  }
+
+  const handleReset = (clearFilters: (param?: FilterResetProps) => void) => {
+    clearFilters({ confirm: true })
+    state.searchText = ''
+  }
+
+  const handleOk = (_: string, item: HostReq) => {
+    const index = dataSource.value.findIndex((data) => data.key === item.key)
+    if (index !== -1) {
+      dataSource.value[index] = item
+    }
+  }
+
+  defineExpose({
+    handleOpen
+  })
+</script>
+
+<template>
+  <div class="install-dependencies">
+    <a-modal
+      :open="open"
+      width="60%"
+      :title="$t('cluster.install_dependencies')"
+      :mask-closable="false"
+      :centered="true"
+      :confirm-loading="installing"
+      :ok-text="allInstallSuccess ? $t('common.confirm') : installing ? 
$t('common.installing') : t('common.install')"
+      :destroy-on-close="true"
+      @ok="handleInstalled"
+      @cancel="handleCancel"
+    >
+      <a-table
+        :loading="loading"
+        row-key="id"
+        :data-source="dataSource"
+        :columns="columns"
+        :pagination="paginationProps"
+        @change="onChange"
+      >
+        <template #customFilterDropdown="{ setSelectedKeys, selectedKeys, 
confirm, clearFilters, column }">
+          <div class="search">
+            <a-input
+              ref="searchInputRef"
+              :placeholder="$t('common.enter_error', [column.title])"
+              :value="selectedKeys[0]"
+              @change="(e: any) => setSelectedKeys(e.target?.value ? 
[e.target?.value] : [])"
+              @press-enter="handleSearch(selectedKeys, confirm, 
column.dataIndex)"
+            />
+            <a-space :size="16">
+              <a-button type="primary" size="small" 
@click="handleSearch(selectedKeys, confirm, column.dataIndex)">
+                {{ $t('common.search') }}
+              </a-button>
+              <a-button size="small" @click="handleReset(clearFilters)"> {{ 
$t('common.reset') }}</a-button>
+            </a-space>
+          </div>
+        </template>
+        <template #customFilterIcon="{ filtered, column }">
+          <svg-icon v-if="column.key === 'hostname'" :name="filtered ? 
'search_activated' : 'search'" />
+          <svg-icon v-else :name="filtered ? 'filter_activated' : 'filter'" />
+        </template>
+        <template #bodyCell="{ record, column }">
+          <template v-if="column.key === 'clusterId'">
+            <span 
:title="`${clusterNameMap.get(record.clusterId)?.displayName}`">
+              {{ clusterNameMap.get(record.clusterId)?.displayName }}
+            </span>
+          </template>
+          <template v-if="column.key === 'status'">
+            <svg-icon :name="record.status.toLowerCase()" />
+            <span :title="`${record.message ? record.message : ''}`">
+              {{ `${$t(`common.${record.status.toLowerCase()}`)}` }}
+              {{ record.message && record.status.toLowerCase() === 'failed' ? 
`:  ${record.message}` : '' }}
+            </span>
+          </template>
+          <template v-if="column.key === 'operation'">
+            <button-group
+              i18n="common"
+              :text-compact="true"
+              :space="24"
+              :groups="operations"
+              :args="record"
+              group-shape="default"
+              group-type="link"
+            ></button-group>
+          </template>
+        </template>
+      </a-table>
+    </a-modal>
+    <host-create ref="hostCreateRef" :is-public="true" @on-ok="handleOk" />
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .search {
+    display: grid;
+    gap: $space-sm;
+    padding: $space-sm;
+  }
+
+  .highlight {
+    background-color: rgb(255, 192, 105);
+    padding: 0;
+  }
+</style>
diff --git a/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue 
b/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue
similarity index 64%
copy from bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
copy to bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue
index 131ff884..689847ae 100644
--- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue
+++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue
@@ -18,19 +18,18 @@
 -->
 
 <script setup lang="ts">
-  import { computed, onActivated, ref, shallowRef, useAttrs } from 'vue'
+  import { computed, ref, shallowRef, toRefs, watch } from 'vue'
   import { useI18n } from 'vue-i18n'
-  import { storeToRefs } from 'pinia'
-  import { useServiceStore } from '@/store/service'
-  import { formatFromByte } from '@/utils/storage'
+  import { Empty, MenuProps } from 'ant-design-vue'
+  import { formatFromByte } from '@/utils/storage.ts'
   import { usePngImage } from '@/utils/tools'
-  import { CommonStatus, CommonStatusTexts } from '@/enums/state'
-  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 { StackVO } from '@/api/stack/types'
+  import { CommonStatus, CommonStatusTexts } from '@/enums/state.ts'
+  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'
 
   type TimeRangeText = '1m' | '15m' | '30m' | '1h' | '6h' | '30h'
   type TimeRangeItem = {
@@ -38,27 +37,28 @@
     time: string
   }
 
+  interface Props {
+    hostInfo: HostVO
+  }
+
+  const props = defineProps<Props>()
+  const { hostInfo } = toRefs(props)
   const { t } = useI18n()
-  const attrs = useAttrs() as ClusterVO
-  const serviceStore = useServiceStore()
+  const currExecuteComponent = shallowRef<ComponentVO>({})
   const currTimeRange = ref<TimeRangeText>('15m')
+  const statusColors = shallowRef<Record<HostStatusType, keyof typeof 
CommonStatusTexts>>({
+    1: 'healthy',
+    2: 'unhealthy',
+    3: 'unknown'
+  })
   const chartData = ref({
     chart1: [],
     chart2: [],
     chart3: [],
     chart4: []
   })
-  const statusColors = shallowRef<Record<ClusterStatusType, keyof typeof 
CommonStatusTexts>>({
-    1: 'healthy',
-    2: 'unhealthy',
-    3: 'unknown'
-  })
-  const { locateStackWithService, serviceNames } = storeToRefs(serviceStore)
-  const clusterDetail = computed(() => ({
-    ...attrs,
-    totalMemory: formatFromByte(attrs.totalMemory as number),
-    totalDisk: formatFromByte(attrs.totalDisk as number)
-  }))
+  const componentsFromCurrentHost = shallowRef<Map<string, ComponentVO[]>>(new 
Map())
+  const needFormatFormByte = computed(() => ['totalMemorySize', 'totalDisk'])
   const noChartData = computed(() => Object.values(chartData.value).every((v) 
=> v.length === 0))
   const timeRanges = computed((): TimeRangeItem[] => [
     {
@@ -86,61 +86,77 @@
       time: ''
     }
   ])
-  const baseConfig = computed((): Partial<Record<keyof ClusterVO, string>> => {
-    return {
-      status: t('overview.cluster_status'),
-      displayName: t('overview.cluster_name'),
-      desc: t('overview.cluster_desc'),
-      totalHost: t('overview.host_count'),
-      totalService: t('overview.service_count'),
-      totalMemory: t('overview.memory'),
-      totalProcessor: t('overview.core_count'),
-      totalDisk: t('overview.disk_size'),
-      createUser: t('overview.creator')
-    }
-  })
-  const unitOfBaseConfig = computed((): Partial<Record<keyof ClusterVO, 
string>> => {
+  const baseConfig = computed((): Partial<Record<keyof HostVO, string>> => {
     return {
-      totalHost: t('overview.unit_host'),
-      totalService: t('overview.unit_service'),
-      totalProcessor: t('overview.unit_processor')
+      status: t('overview.host_status'),
+      hostname: t('overview.host_name'),
+      desc: t('overview.host_desc'),
+      clusterDisplayName: t('common.cluster'),
+      componentNum: t('overview.component_count'),
+      os: t('overview.os'),
+      ipv4: t('overview.ip_v4'),
+      ipv6: t('overview.ip_v6'),
+      arch: t('overview.arch'),
+      availableProcessors: t('overview.core_count'),
+      totalMemorySize: t('overview.memory'),
+      totalDisk: t('overview.disk_size')
     }
   })
-  const detailKeys = computed(() => Object.keys(baseConfig.value) as (keyof 
ClusterVO)[])
-  const serviceOperates = computed(() => [
+  const unitOfBaseConfig = computed(
+    (): Partial<Record<keyof HostVO, string>> => ({
+      componentNum: t('overview.unit_component'),
+      availableProcessors: t('overview.unit_core')
+    })
+  )
+  const detailKeys = computed((): (keyof HostVO)[] => 
Object.keys(baseConfig.value))
+  const componentOperates = computed(() => [
     {
       action: 'start',
-      text: t('common.start', [t('common.service')])
+      text: t('common.start', [t('common.component')])
     },
     {
       action: 'restart',
-      text: t('common.restart', [t('common.service')])
+      text: t('common.restart', [t('common.component')])
     },
     {
       action: 'stop',
-      text: t('common.stop', [t('common.service')])
+      text: t('common.stop', [t('common.component')])
     }
   ])
 
-  const handleServiceOperate: MenuProps['onClick'] = (item) => {
+  const handleHostOperate: MenuProps['onClick'] = (item) => {
     console.log('item :>> ', item.key)
+    console.log(currExecuteComponent.value)
   }
 
   const handleTimeRange = (time: TimeRangeItem) => {
     currTimeRange.value = time.text
   }
 
-  const getServices = (filters?: ServiceListParams) => {
-    attrs.id != undefined && serviceStore.getServices(attrs.id, filters)
-  }
-
-  const servicesFromCurrentCluster = (stack: StackVO) => {
-    return stack.services.filter((v) => serviceNames.value.includes(v.name))
+  const recordClickComp = (comp: ComponentVO) => {
+    currExecuteComponent.value = comp
   }
 
-  onActivated(() => {
-    getServices()
-  })
+  watch(
+    () => hostInfo.value,
+    async (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)
+        }
+      }
+    }
+  )
 </script>
 
 <template>
@@ -171,23 +187,25 @@
                         <a-tag
                           v-if="base === 'status'"
                           class="reset-tag"
-                          
:color="CommonStatus[statusColors[clusterDetail[base] as ClusterStatusType]]"
+                          :color="CommonStatus[statusColors[hostInfo[base] as 
ClusterStatusType]]"
                         >
-                          <status-dot 
:color="CommonStatus[statusColors[clusterDetail[base] as ClusterStatusType]]" />
-                          {{
-                            clusterDetail[base] &&
-                            $t(`common.${statusColors[clusterDetail[base] as 
ClusterStatusType]}`)
-                          }}
+                          <status-dot 
:color="CommonStatus[statusColors[hostInfo[base] as ClusterStatusType]]" />
+                          {{ hostInfo[base] && 
$t(`common.${statusColors[hostInfo[base] as ClusterStatusType]}`) }}
                         </a-tag>
-                        <a-typography-text
-                          v-else
-                          class="desc-sub-item-desc-column"
-                          :content="
-                            Object.keys(unitOfBaseConfig).includes(base)
-                              ? `${clusterDetail[base]} 
${unitOfBaseConfig[base]}`
-                              : `${clusterDetail[base]}`
-                          "
-                        />
+                        <a-typography-text v-else 
class="desc-sub-item-desc-column">
+                          <span 
v-if="Object.keys(unitOfBaseConfig).includes(base as string)">
+                            {{ `${hostInfo[base]} ${unitOfBaseConfig[base]}` }}
+                          </span>
+                          <span v-else>
+                            {{
+                              `${
+                                needFormatFormByte.includes(base as string)
+                                  ? formatFromByte(hostInfo[base])
+                                  : hostInfo[base] || '--'
+                              }`
+                            }}
+                          </span>
+                        </a-typography-text>
                       </div>
                     </template>
                   </div>
@@ -196,11 +214,11 @@
             </a-descriptions>
           </div>
         </div>
-        <!-- service info -->
-        <template v-if="locateStackWithService.length == 0">
-          <div class="service-info">
+
+        <template v-if="componentsFromCurrentHost.size === 0">
+          <div class="component-info">
             <div class="box-title">
-              <a-typography-text strong :content="$t('overview.service_info')" 
/>
+              <a-typography-text strong 
:content="$t('overview.component_info')" />
             </div>
             <div class="box-empty">
               <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" />
@@ -209,28 +227,25 @@
         </template>
         <a-descriptions v-else layout="vertical" bordered :column="1">
           <template #title>
-            <a-typography-text strong :content="$t('overview.service_info')" />
+            <a-typography-text strong :content="$t('overview.component_info')" 
/>
           </template>
-          <a-descriptions-item v-for="stack in locateStackWithService" 
:key="stack.stackName">
+          <a-descriptions-item v-for="stack in 
componentsFromCurrentHost.keys()" :key="stack">
             <template #label>
               <div class="desc-sub-label">
-                <a-typography-text
-                  strong
-                  :content="stack.stackName.charAt(0).toUpperCase() + 
stack.stackName.slice(1) + ' Stack'"
-                />
-                <a-typography-text type="secondary" 
:content="`${stack.stackName}-${stack.stackVersion}`" />
+                <a-typography-text strong :content="stack.split('-')[0] + ' 
Stack'" />
+                <a-typography-text type="secondary" 
:content="`${stack.toLowerCase()}`" />
               </div>
             </template>
-            <div v-for="service in servicesFromCurrentCluster(stack)" 
:key="service.id" class="service-item">
-              <a-avatar v-if="service.name" 
:src="usePngImage(service.name.toLowerCase())" :size="16" />
-              <a-typography-text :content="service.displayName" />
+            <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">
+                <a-button type="text" shape="circle" size="small" 
@click="recordClickComp(comp)">
                   <svg-icon name="more" style="margin: 0" />
                 </a-button>
                 <template #overlay>
-                  <a-menu @click="handleServiceOperate">
-                    <a-menu-item v-for="operate in serviceOperates" 
:key="operate.action">
+                  <a-menu @click="handleHostOperate">
+                    <a-menu-item v-for="operate in componentOperates" 
:key="operate.action">
                       <span>{{ operate.text }}</span>
                     </a-menu-item>
                   </a-menu>
@@ -291,10 +306,12 @@
 <style lang="scss" scoped>
   :deep(.ant-avatar) {
     border-radius: 4px;
+
     img {
       object-fit: contain !important;
     }
   }
+
   .box {
     &-title {
       @include flexbox($justify: space-between);
@@ -329,18 +346,19 @@
     &:hover {
       color: $color-primary-text-hover;
     }
+
     &-activated {
       color: $color-primary-text;
     }
   }
 
-  .service-item {
+  .component-item {
     display: grid;
     grid-template-columns: auto 1fr auto;
     gap: $space-md;
     align-items: center;
     padding: 12px 16px;
-    border-bottom: 1px solid #f0f0f0;
+    border-top: 1px solid #f0f0f0;
   }
 
   .chart-item-wrp {
@@ -367,9 +385,11 @@
     :deep(.ant-descriptions-view) {
       overflow: hidden;
       border-color: $color-border;
+
       .ant-descriptions-item-label {
         padding: 0;
       }
+
       .ant-descriptions-item-content {
         padding: 0;
       }
@@ -378,6 +398,7 @@
     .desc-sub-item-wrp {
       @include flexbox($gap: $space-md);
       padding: $space-md;
+
       :deep(.ant-tag) {
         width: fit-content;
         @include flexbox($align: center, $gap: 4px);
@@ -388,8 +409,10 @@
       display: grid;
       grid-template-columns: max-content 1fr;
       gap: 16px;
+
       &-desc {
         display: contents;
+
         &-column {
           text-align: left;
         }
diff --git a/bigtop-manager-ui/src/router/routes/modules/clusters.ts 
b/bigtop-manager-ui/src/router/routes/modules/clusters.ts
index 1c9ecd9f..031bd82b 100644
--- a/bigtop-manager-ui/src/router/routes/modules/clusters.ts
+++ b/bigtop-manager-ui/src/router/routes/modules/clusters.ts
@@ -137,6 +137,15 @@ const routes: RouteRecordRaw[] = [
               hidden: true,
               activeMenu: '/cluster-manage/hosts'
             }
+          },
+          {
+            name: 'HostDetail',
+            path: 'detail',
+            component: () => import('@/pages/cluster-manage/hosts/detail.vue'),
+            meta: {
+              hidden: true,
+              activeMenu: '/cluster-manage/hosts'
+            }
           }
         ]
       }
diff --git a/bigtop-manager-ui/src/store/cluster/index.ts 
b/bigtop-manager-ui/src/store/cluster/index.ts
index 365b9fb7..e71a22dc 100644
--- a/bigtop-manager-ui/src/store/cluster/index.ts
+++ b/bigtop-manager-ui/src/store/cluster/index.ts
@@ -83,6 +83,9 @@ export const useClusterStore = defineStore(
     }
   },
   {
-    persist: false
+    persist: {
+      storage: sessionStorage,
+      paths: ['clusters']
+    }
   }
 )
diff --git a/bigtop-manager-ui/src/store/installed/index.ts 
b/bigtop-manager-ui/src/store/installed/index.ts
index 4a417345..e56a2475 100644
--- a/bigtop-manager-ui/src/store/installed/index.ts
+++ b/bigtop-manager-ui/src/store/installed/index.ts
@@ -57,6 +57,7 @@ export const useInstalledStore = defineStore(
     }
 
     return {
+      serviceStore,
       installedMap,
       getServicesOfInfra,
       setInstalledMapKey,
diff --git a/bigtop-manager-ui/src/store/menu/index.ts 
b/bigtop-manager-ui/src/store/menu/index.ts
index 492c4901..eef4e6f7 100644
--- a/bigtop-manager-ui/src/store/menu/index.ts
+++ b/bigtop-manager-ui/src/store/menu/index.ts
@@ -45,6 +45,24 @@ export const useMenuStore = defineStore(
       const path = route.matched.at(-1)?.path
       return path?.includes(RouteExceptions.DYNAMIC_ROUTE_MATCH)
     })
+
+    const updateSiderItemByClusters = (formatSider: MenuItem[]) => {
+      formatSider.forEach((item) => {
+        if (item.name == RouteExceptions.SPECIAL_ROUTE_NAME) {
+          item.children = clusters.value.map((v) => {
+            return {
+              icon: '',
+              key: `${item.key}/${v.displayName}/${v.id}`,
+              label: v.displayName || '',
+              title: v.displayName || '',
+              activeMenu: item.key,
+              status: v.status || 3
+            }
+          })
+        }
+      })
+    }
+
     const siderMenus = computed((): MenuItem[] => {
       const siderMenuTemplate = 
baseRoutesMap.value.get(headerSelectedKey.value) || []
       const formatSider = cloneDeep(siderMenuTemplate)
@@ -86,23 +104,6 @@ export const useMenuStore = defineStore(
       }
     })
 
-    const updateSiderItemByClusters = (formatSider: MenuItem[]) => {
-      formatSider.forEach((item) => {
-        if (item.name == RouteExceptions.SPECIAL_ROUTE_NAME) {
-          item.children = clusters.value.map((v) => {
-            return {
-              icon: '',
-              key: `${item.key}/${v.displayName}/${v.id}`,
-              label: v.displayName || '',
-              title: v.displayName || '',
-              activeMenu: item.key,
-              status: v.status || 3
-            }
-          })
-        }
-      })
-    }
-
     const formatRouteToMenu = (tree: any[], upPath = ''): MenuItem[] => {
       return tree
         .filter(({ meta }) => !meta?.hidden)

Reply via email to