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 c49aa517 BIGTOP-4466: Add charts for cluster and host metrics (#242) c49aa517 is described below commit c49aa517fdbdb12f0c1ba82600a8394ba3844a5d Author: Fdefined <55788435+fu-des...@users.noreply.github.com> AuthorDate: Sun Jul 13 13:26:28 2025 +0800 BIGTOP-4466: Add charts for cluster and host metrics (#242) --- .../src/{utils/storage.ts => api/metrics/index.ts} | 25 +-- .../src/{utils/storage.ts => api/metrics/types.ts} | 34 ++-- .../src/components/charts/category-chart.vue | 144 ++++++++++----- .../src/components/charts/gauge-chart.vue | 16 +- .../src/components/service-management/overview.vue | 5 +- bigtop-manager-ui/src/composables/use-chart.ts | 16 +- bigtop-manager-ui/src/locales/en_US/overview.ts | 4 +- bigtop-manager-ui/src/locales/zh_CN/overview.ts | 4 +- .../src/pages/cluster-manage/cluster/overview.vue | 110 +++++++----- .../src/pages/cluster-manage/hosts/overview.vue | 197 ++++++++++++--------- bigtop-manager-ui/src/utils/storage.ts | 39 +++- 11 files changed, 365 insertions(+), 229 deletions(-) diff --git a/bigtop-manager-ui/src/utils/storage.ts b/bigtop-manager-ui/src/api/metrics/index.ts similarity index 60% copy from bigtop-manager-ui/src/utils/storage.ts copy to bigtop-manager-ui/src/api/metrics/index.ts index 1d35b5b1..e3502554 100644 --- a/bigtop-manager-ui/src/utils/storage.ts +++ b/bigtop-manager-ui/src/api/metrics/index.ts @@ -17,22 +17,13 @@ * under the License. */ -export const formatFromByte = (value: number): string => { - if (isNaN(value)) { - return '' - } +import { get } from '@/api/request-util' +import type { MetricsData, TimeRangeType } from './types' - if (value < 1024) { - return `${value} B` - } else if (value < 1024 ** 2) { - return `${(value / 1024).toFixed(2)} KB` - } else if (value < 1024 ** 3) { - return `${(value / 1024 ** 2).toFixed(2)} MB` - } else if (value < 1024 ** 4) { - return `${(value / 1024 ** 3).toFixed(2)} GB` - } else if (value < 1024 ** 5) { - return `${(value / 1024 ** 4).toFixed(2)} TB` - } else { - return `${(value / 1024 ** 5).toFixed(2)} PB` - } +export const getClusterMetricsInfo = (paramsPath: { id: number }, params: { interval: TimeRangeType }) => { + return get<MetricsData>(`/metrics/clusters/${paramsPath.id}`, params) +} + +export const getHostMetricsInfo = (paramsPath: { id: number }, params: { interval: TimeRangeType }) => { + return get<MetricsData>(`/metrics/hosts/${paramsPath.id}`, params) } diff --git a/bigtop-manager-ui/src/utils/storage.ts b/bigtop-manager-ui/src/api/metrics/types.ts similarity index 60% copy from bigtop-manager-ui/src/utils/storage.ts copy to bigtop-manager-ui/src/api/metrics/types.ts index 1d35b5b1..86e4ec30 100644 --- a/bigtop-manager-ui/src/utils/storage.ts +++ b/bigtop-manager-ui/src/api/metrics/types.ts @@ -17,22 +17,20 @@ * under the License. */ -export const formatFromByte = (value: number): string => { - if (isNaN(value)) { - return '' - } - - if (value < 1024) { - return `${value} B` - } else if (value < 1024 ** 2) { - return `${(value / 1024).toFixed(2)} KB` - } else if (value < 1024 ** 3) { - return `${(value / 1024 ** 2).toFixed(2)} MB` - } else if (value < 1024 ** 4) { - return `${(value / 1024 ** 3).toFixed(2)} GB` - } else if (value < 1024 ** 5) { - return `${(value / 1024 ** 4).toFixed(2)} TB` - } else { - return `${(value / 1024 ** 5).toFixed(2)} PB` - } +export type TimeRangeType = '1m' | '5m' | '15m' | '30m' | '1h' | '2h' +export type MetricsData = { + cpuUsageCur: string + memoryUsageCur: string + diskUsageCur: string + fileDescriptorUsage: string + diskReadCur: string + diskWriteCur: string + cpuUsage: string[] + systemLoad1: string[] + systemLoad5: string[] + systemLoad15: string[] + memoryUsage: string[] + diskRead: string[] + diskWrite: string[] + timestamps: string[] } diff --git a/bigtop-manager-ui/src/components/charts/category-chart.vue b/bigtop-manager-ui/src/components/charts/category-chart.vue index c7efe490..19a11619 100644 --- a/bigtop-manager-ui/src/components/charts/category-chart.vue +++ b/bigtop-manager-ui/src/components/charts/category-chart.vue @@ -20,25 +20,49 @@ <script setup lang="ts"> import dayjs from 'dayjs' import { computed, onMounted, toRefs, watchEffect } from 'vue' + import { roundFixed } from '@/utils/storage' import { type EChartsOption, useChart } from '@/composables/use-chart' - const props = defineProps<{ + interface Props { chartId: string title: string - data?: any[] - timeDistance?: string - }>() + data?: any + legendMap?: [string, string][] | undefined + config?: EChartsOption + xAxisData?: string[] + formatter?: { + yAxis?: (value: unknown) => string + tooltip?: (value: unknown) => string + } + } + + const props = withDefaults(defineProps<Props>(), { + legendMap: undefined, + xAxisData: () => { + return [] + }, + data: () => { + return {} + }, + config: () => { + return {} + }, + formatter: () => { + return {} + } + }) - const { data, chartId, title, timeDistance } = toRefs(props) + const { data, chartId, title, config, legendMap, xAxisData, formatter } = toRefs(props) const { initChart, setOptions } = useChart() + const baseConfig = { type: 'line' } const option = computed( (): EChartsOption => ({ grid: { - top: '20px', + top: '30px', left: '40px', right: '30px', - bottom: '20px' + bottom: '30px' }, tooltip: { trigger: 'axis', @@ -47,16 +71,12 @@ textStyle: { color: '#fff' }, - axisPointer: { - type: 'cross', - crossStyle: { - color: '#999' - } - } + formatter: createTooltipFormatter(formatter.value.tooltip) }, xAxis: [ { type: 'category', + boundaryGap: false, data: [], axisPointer: { type: 'line' @@ -69,18 +89,11 @@ yAxis: [ { type: 'value', - axisPointer: { - type: 'shadow', - label: { - formatter: '{value} %' - } - }, - min: 0, - max: 100, - interval: 20, axisLabel: { + width: 32, fontSize: 8, - formatter: '{value} %' + overflow: 'truncate', + formatter: formatter.value.yAxis ?? '{value} %' } } ], @@ -98,31 +111,50 @@ }) ) - const intervalToMs = (interval: string): number => { - const unit = interval.replace(/\d+/g, '') - const value = parseInt(interval) - - switch (unit) { - case 'm': - return value * 60 * 1000 - case 'h': - return value * 60 * 60 * 1000 - default: - throw new Error('Unsupported interval: ' + interval) - } + const defaultTooltipFormatter = (val: unknown) => { + const num = roundFixed(val) + return num ? `${num} %` : '--' } - const getTimePoints = (interval: string = '15m'): string[] => { - const now = dayjs() - const gap = intervalToMs(interval) - const result: string[] = [] + const tooltipHtml = (item: any) => { + return ` + <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; gap: 12px"> + <div style="display: flex; align-items: center;"> + <div>${item.marker}${item.seriesName}</div> + </div> + <div>${item.valueText}</div> + </div> + ` + } - for (let i = 5; i >= 0; i--) { - const time = now.subtract(i * gap, 'millisecond') - result.push(time.format('HH:mm')) + const createTooltipFormatter = (formatValue?: (value: unknown) => string) => { + const format = formatValue ?? defaultTooltipFormatter + console.log('format :>> ', format) + return (params: any) => { + const title = params[0]?.axisValueLabel ?? '' + const lines = params + .map((item: any) => { + const valueText = format(item.value) + return tooltipHtml({ ...item, valueText }) + }) + .join('') + return `<div style="margin-bottom: 4px;">${title}</div>${lines}` } + } - return result + /** + * Generates ECharts series config by mapping legend keys to data and formatting values. + * + * @param data - A partial object containing data arrays for each series key. + * @param legendMap - An array of [key, displayName] pairs. + * @returns An array of ECharts series config objects with populated and formatted data. + */ + const generateChartSeries = <T,>(data: Partial<T>, legendMap: [string, string][]) => { + return legendMap.map(([key, name]) => ({ + name, + ...baseConfig, + data: (data[key] || []).map((v: unknown) => roundFixed(v)) + })) } onMounted(() => { @@ -131,14 +163,28 @@ }) watchEffect(() => { - setOptions({ - xAxis: [{ data: getTimePoints(timeDistance.value) || [] }] - }) - }) + let series = [] as any, + legend = [] as any + + if (legendMap.value) { + legend = new Map(legendMap.value).values() + series = generateChartSeries(data.value, legendMap.value) + } else { + series = [ + { + name: title.value.toLowerCase(), + data: data.value.map((v) => roundFixed(v)) + } + ] + } - watchEffect(() => { setOptions({ - series: [{ data: [{ value: data.value ?? [] }] }] + xAxis: xAxisData.value + ? [{ data: xAxisData.value?.map((v) => dayjs(Number(v) * 1000).format('HH:mm')) || [] }] + : [], + ...config.value, + legend, + series }) }) </script> diff --git a/bigtop-manager-ui/src/components/charts/gauge-chart.vue b/bigtop-manager-ui/src/components/charts/gauge-chart.vue index 946da83c..099867c3 100644 --- a/bigtop-manager-ui/src/components/charts/gauge-chart.vue +++ b/bigtop-manager-ui/src/components/charts/gauge-chart.vue @@ -19,15 +19,17 @@ <script setup lang="ts"> import { onMounted, shallowRef, toRefs, watchEffect } from 'vue' + import { type EChartsOption, useChart } from '@/composables/use-chart' + import { roundFixed } from '@/utils/storage' interface GaugeChartProps { chartId: string title: string - percent?: number + percent?: string } - const props = withDefaults(defineProps<GaugeChartProps>(), { percent: 0 }) + const props = withDefaults(defineProps<GaugeChartProps>(), { percent: '0.00' }) const { percent, chartId, title } = toRefs(props) const { initChart, setOptions } = useChart() @@ -77,13 +79,13 @@ }, detail: { valueAnimation: true, - formatter: '{value}%', color: 'inherit', - fontSize: 18 + fontSize: 18, + formatter: (val: number) => `${roundFixed(val, 2, '0.00', false)}%` }, data: [ { - value: 0 * 100 + value: 0.0 } ] } @@ -96,9 +98,7 @@ }) watchEffect(() => { - setOptions({ - series: [{ data: [{ value: percent.value.toFixed(2) === 'NaN' ? 0 : percent.value.toFixed(2) }] }] - }) + setOptions({ series: [{ data: [{ value: percent.value }] }] }) }) </script> diff --git a/bigtop-manager-ui/src/components/service-management/overview.vue b/bigtop-manager-ui/src/components/service-management/overview.vue index 3cabfb30..69e29e21 100644 --- a/bigtop-manager-ui/src/components/service-management/overview.vue +++ b/bigtop-manager-ui/src/components/service-management/overview.vue @@ -23,6 +23,7 @@ import { CommonStatus, CommonStatusTexts } from '@/enums/state' import GaugeChart from '@/components/charts/gauge-chart.vue' import CategoryChart from '@/components/charts/category-chart.vue' + import { Empty } from 'ant-design-vue' import type { ServiceVO, ServiceStatusType } from '@/api/service/types' type TimeRangeText = '1m' | '15m' | '30m' | '1h' | '6h' | '30h' @@ -166,7 +167,7 @@ </div> <template v-if="noChartData"> <div class="box-empty"> - <a-empty /> + <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" /> </div> </template> <a-row v-else class="box-content"> @@ -213,7 +214,7 @@ &-content { border-radius: 8px; - overflow: hidden; + overflow: visible; box-sizing: border-box; border: 1px solid $color-border; } diff --git a/bigtop-manager-ui/src/composables/use-chart.ts b/bigtop-manager-ui/src/composables/use-chart.ts index c0c61aad..2ca626cc 100644 --- a/bigtop-manager-ui/src/composables/use-chart.ts +++ b/bigtop-manager-ui/src/composables/use-chart.ts @@ -19,23 +19,33 @@ import * as echarts from 'echarts/core' import { GaugeChart, GaugeSeriesOption, LineChart, LineSeriesOption } from 'echarts/charts' -import { GridComponent, GridComponentOption, TooltipComponent, TooltipComponentOption } from 'echarts/components' +import { + TitleComponent, + GridComponent, + GridComponentOption, + TooltipComponent, + TooltipComponentOption, + LegendComponent, + LegendComponentOption +} from 'echarts/components' import { UniversalTransition } from 'echarts/features' import { CanvasRenderer } from 'echarts/renderers' import { onBeforeUnmount, onMounted, shallowRef } from 'vue' export type EChartsOption = echarts.ComposeOption< - GaugeSeriesOption | GridComponentOption | TooltipComponentOption | LineSeriesOption + GaugeSeriesOption | GridComponentOption | TooltipComponentOption | LineSeriesOption | LegendComponentOption > echarts.use([ + TitleComponent, GaugeChart, CanvasRenderer, GridComponent, LineChart, TooltipComponent, CanvasRenderer, - UniversalTransition + UniversalTransition, + LegendComponent ]) export const useChart = () => { diff --git a/bigtop-manager-ui/src/locales/en_US/overview.ts b/bigtop-manager-ui/src/locales/en_US/overview.ts index 4446a038..78b5039a 100644 --- a/bigtop-manager-ui/src/locales/en_US/overview.ts +++ b/bigtop-manager-ui/src/locales/en_US/overview.ts @@ -49,5 +49,7 @@ export default { service_name: 'Name', service_version: 'Version', metrics: 'Metrics', - kerberos: 'Kerberos' + kerberos: 'Kerberos', + system_load: 'System Load', + disk_io: 'Disk I/O' } diff --git a/bigtop-manager-ui/src/locales/zh_CN/overview.ts b/bigtop-manager-ui/src/locales/zh_CN/overview.ts index 9e6ddd83..2d3fcc7c 100644 --- a/bigtop-manager-ui/src/locales/zh_CN/overview.ts +++ b/bigtop-manager-ui/src/locales/zh_CN/overview.ts @@ -49,5 +49,7 @@ export default { service_name: '服务名', service_version: '服务版本', metrics: '指标监控', - kerberos: 'Kerberos' + kerberos: 'Kerberos', + system_load: '系统负载', + disk_io: '磁盘 I/O' } 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 3a20ab30..69b71723 100644 --- a/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue +++ b/bigtop-manager-ui/src/pages/cluster-manage/cluster/overview.vue @@ -18,7 +18,7 @@ --> <script setup lang="ts"> - import { computed, onActivated, ref, shallowRef, toRefs, watchEffect } from 'vue' + import { computed, onActivated, onDeactivated, onUnmounted, ref, shallowRef, toRefs, watchEffect } from 'vue' import { useI18n } from 'vue-i18n' import { storeToRefs } from 'pinia' import { formatFromByte } from '@/utils/storage' @@ -30,6 +30,8 @@ import { useClusterStore } from '@/store/cluster' import { Empty } from 'ant-design-vue' import { useRoute } from 'vue-router' + import { getClusterMetricsInfo } from '@/api/metrics' + import { useIntervalFn } from '@vueuse/core' import GaugeChart from '@/components/charts/gauge-chart.vue' import CategoryChart from '@/components/charts/category-chart.vue' @@ -39,12 +41,7 @@ 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 = { - text: TimeRangeText - time: string - } + import type { MetricsData, TimeRangeType } from '@/api/metrics/types' const props = defineProps<{ payload: ClusterVO }>() const emits = defineEmits<{ (event: 'update:payload', value: ClusterVO): void }>() @@ -55,21 +52,19 @@ const stackStore = useStackStore() const serviceStore = useServiceStore() const clusterStore = useClusterStore() - const currTimeRange = ref<TimeRangeText>('15m') - const chartData = ref({ - chart1: [], - chart2: [], - chart3: [], - chart4: [] - }) + + const currTimeRange = ref<TimeRangeType>('5m') + const chartData = ref<Partial<MetricsData>>({}) + + const timeRanges = shallowRef<TimeRangeType[]>(['1m', '5m', '15m', '30m', '1h', '2h']) + const locateStackWithService = shallowRef<StackVO[]>([]) const statusColors = shallowRef<Record<ClusterStatusType, keyof typeof CommonStatusTexts>>({ 1: 'healthy', 2: 'unhealthy', 3: 'unknown' }) - const { serviceNames } = storeToRefs(serviceStore) - const locateStackWithService = shallowRef<StackVO[]>([]) + const { serviceNames } = storeToRefs(serviceStore) const { payload } = toRefs(props) const clusterDetail = computed(() => ({ @@ -78,17 +73,6 @@ totalDisk: formatFromByte(payload.value.totalDisk as number) })) - const clusterId = computed(() => route.params.id as unknown as number) - const noChartData = computed(() => Object.values(chartData.value).every((v) => v.length === 0)) - const timeRanges = computed((): TimeRangeItem[] => [ - { text: '1m', time: '' }, - { text: '15m', time: '' }, - { text: '30m', time: '' }, - { text: '1h', time: '' }, - { text: '6h', time: '' }, - { text: '30h', time: '' } - ]) - const baseConfig = computed( (): Partial<Record<keyof ClusterVO, string>> => ({ status: t('overview.cluster_status'), @@ -111,12 +95,15 @@ }) ) + const serviceOperates = computed(() => ({ + Start: t('common.start', [t('common.service')]), + Restart: t('common.restart', [t('common.service')]), + Stop: t('common.stop', [t('common.service')]) + })) + + const clusterId = computed(() => route.params.id as unknown as number) + const noChartData = computed(() => Object.values(chartData.value).length === 0) const detailKeys = computed(() => Object.keys(baseConfig.value) as (keyof ClusterVO)[]) - const serviceOperates = computed(() => [ - { action: 'Start', text: t('common.start', [t('common.service')]) }, - { action: 'Restart', text: t('common.restart', [t('common.service')]) }, - { action: 'Stop', text: t('common.stop', [t('common.service')]) } - ]) const handleServiceOperate = async (item: MenuItem, service: ServiceVO) => { try { @@ -131,19 +118,38 @@ } } - const handleTimeRange = (time: TimeRangeItem) => { - currTimeRange.value = time.text + const handleTimeRange = (time: TimeRangeType) => { + if (currTimeRange.value !== time) { + currTimeRange.value = time + getClusterMetrics() + } } const servicesFromCurrentCluster = (stack: StackVO) => { return stack.services.filter((v) => serviceNames.value.includes(v.name)) } + const getClusterMetrics = async () => { + try { + chartData.value = await getClusterMetricsInfo({ id: clusterId.value }, { interval: currTimeRange.value }) + } catch (error) { + console.log('Failed to fetch cluster metrics:', error) + } + } + + const { pause, resume } = useIntervalFn(getClusterMetrics, 30000, { immediate: true }) + onActivated(async () => { await clusterStore.getClusterDetail(clusterId.value) emits('update:payload', clusterStore.currCluster) + getClusterMetrics() + resume() }) + onDeactivated(() => pause()) + + onUnmounted(() => pause()) + watchEffect(() => { locateStackWithService.value = stackStore.stacks.filter((item) => item.services.some((service) => service.name && serviceNames.value.includes(service.name)) @@ -238,8 +244,8 @@ </a-button> <template #overlay> <a-menu @click="handleServiceOperate($event, service)"> - <a-menu-item v-for="operate in serviceOperates" :key="operate.action"> - <span>{{ operate.text }}</span> + <a-menu-item v-for="[operate, text] of Object.entries(serviceOperates)" :key="operate"> + <span>{{ text }}</span> </a-menu-item> </a-menu> </template> @@ -254,40 +260,54 @@ <a-space :size="12"> <div v-for="time in timeRanges" - :key="time.text" + :key="time" tabindex="0" class="time-range" - :class="{ 'time-range-activated': currTimeRange === time.text }" + :class="{ 'time-range-activated': currTimeRange === time }" @click="handleTimeRange(time)" > - {{ time.text }} + {{ time }} </div> </a-space> </div> <template v-if="noChartData"> <div class="box-empty"> - <a-empty /> + <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" /> </div> </template> <a-row v-else class="box-content"> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <gauge-chart chart-id="chart1" :title="$t('overview.memory_usage')" /> + <gauge-chart + chart-id="chart1" + :percent="chartData?.memoryUsageCur" + :title="$t('overview.memory_usage')" + /> </div> </a-col> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <gauge-chart chart-id="chart2" :title="$t('overview.cpu_usage')" /> + <gauge-chart chart-id="chart2" :percent="chartData?.cpuUsageCur" :title="$t('overview.cpu_usage')" /> </div> </a-col> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <category-chart chart-id="chart4" :title="$t('overview.cpu_usage')" /> + <category-chart + chart-id="chart3" + :x-axis-data="chartData?.timestamps" + :data="chartData?.memoryUsage ?? []" + :title="$t('overview.memory_usage')" + /> </div> </a-col> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <category-chart chart-id="chart3" :title="$t('overview.memory_usage')" /> + <category-chart + chart-id="chart4" + :x-axis-data="chartData?.timestamps" + :data="chartData?.cpuUsage ?? []" + :title="$t('overview.cpu_usage')" + /> </div> </a-col> </a-row> @@ -313,7 +333,7 @@ &-content { border-radius: 8px; - overflow: hidden; + overflow: visible; box-sizing: border-box; border: 1px solid $color-border; } 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 ce1b8059..78d1495d 100644 --- a/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue +++ b/bigtop-manager-ui/src/pages/cluster-manage/hosts/overview.vue @@ -18,7 +18,7 @@ --> <script setup lang="ts"> - import { computed, ref, shallowRef, toRefs, watch } from 'vue' + import { computed, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Empty } from 'ant-design-vue' import { formatFromByte } from '@/utils/storage.ts' @@ -29,72 +29,43 @@ import { useStackStore } from '@/store/stack' import { getComponentsByHost } from '@/api/hosts' import { Command } from '@/api/command/types' + import { getHostMetricsInfo } from '@/api/metrics' + import { useIntervalFn } from '@vueuse/core' + import GaugeChart from '@/components/charts/gauge-chart.vue' import CategoryChart from '@/components/charts/category-chart.vue' + + import type { MenuItem } from '@/store/menu/types' 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' + import type { MetricsData, TimeRangeType } from '@/api/metrics/types' - type TimeRangeText = '1m' | '15m' | '30m' | '1h' | '6h' | '30h' - type TimeRangeItem = { - text: TimeRangeText - time: string - } + type StatusColorType = Record<HostStatusType, keyof typeof CommonStatusTexts> interface Props { hostInfo: HostVO } const props = defineProps<Props>() - const { hostInfo } = toRefs(props) + const { t } = useI18n() const stackStore = useStackStore() const serviceStore = useServiceStore() const jobProgressStore = useJobProgress() - 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 currTimeRange = ref<TimeRangeType>('5m') + const chartData = ref<Partial<MetricsData>>({}) + 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[] => [ - { - text: '1m', - time: '' - }, - { - text: '15m', - time: '' - }, - { - text: '30m', - time: '' - }, - { - text: '1h', - time: '' - }, - { - text: '6h', - time: '' - }, - { - text: '30h', - time: '' - } - ]) - const baseConfig = computed((): Partial<Record<keyof HostVO, string>> => { - return { + const needFormatFormByte = shallowRef(['totalMemorySize', 'totalDisk']) + const timeRanges = shallowRef<TimeRangeType[]>(['1m', '5m', '15m', '30m', '1h', '2h']) + const statusColors = shallowRef<StatusColorType>({ 1: 'healthy', 2: 'unhealthy', 3: 'unknown' }) + + const { hostInfo } = toRefs(props) + + const baseConfig = computed( + (): Partial<Record<keyof HostVO, string>> => ({ status: t('overview.host_status'), hostname: t('overview.hostname'), desc: t('overview.host_desc'), @@ -107,29 +78,24 @@ availableProcessors: t('overview.core_count'), totalMemorySize: t('overview.memory'), totalDisk: t('overview.disk_size') - } - }) + }) + ) + const unitOfBaseConfig = computed( (): Partial<Record<keyof HostVO, string>> => ({ componentNum: t('overview.unit_component'), availableProcessors: t('overview.unit_core') }) ) + + const componentOperates = computed(() => ({ + Start: t('common.start', [t('common.component')]), + Restart: t('common.restart', [t('common.component')]), + Stop: t('common.stop', [t('common.component')]) + })) + const detailKeys = computed((): (keyof HostVO)[] => Object.keys(baseConfig.value)) - const componentOperates = computed(() => [ - { - action: 'Start', - text: t('common.start', [t('common.component')]) - }, - { - action: 'Restart', - text: t('common.restart', [t('common.component')]) - }, - { - action: 'Stop', - text: t('common.stop', [t('common.component')]) - } - ]) + const noChartData = computed(() => Object.values(chartData.value).length === 0) const handleHostOperate = async (item: MenuItem, component: ComponentVO) => { const { serviceName } = component @@ -153,8 +119,20 @@ } } - const handleTimeRange = (time: TimeRangeItem) => { - currTimeRange.value = time.text + const handleTimeRange = (time: TimeRangeType) => { + if (currTimeRange.value == time) { + return + } + currTimeRange.value = time + getHostMetrics() + } + + const getHostMetrics = async () => { + try { + chartData.value = await getHostMetricsInfo({ id: hostInfo.value.id! }, { interval: currTimeRange.value }) + } catch (error) { + console.log('Failed to fetch host metrics:', error) + } } const getComponentInfo = async () => { @@ -173,14 +151,24 @@ } } + const { pause, resume } = useIntervalFn(getHostMetrics, 30000, { immediate: true }) + watch( () => hostInfo.value, (val) => { if (val.id) { getComponentInfo() + getHostMetrics() + resume() + } else { + pause() } } ) + + onUnmounted(() => { + pause() + }) </script> <template> @@ -272,8 +260,8 @@ </a-button> <template #overlay> <a-menu @click="handleHostOperate($event, comp)"> - <a-menu-item v-for="operate in componentOperates" :key="operate.action"> - <span>{{ operate.text }}</span> + <a-menu-item v-for="[operate, text] of Object.entries(componentOperates)" :key="operate"> + <span>{{ text }}</span> </a-menu-item> </a-menu> </template> @@ -288,40 +276,91 @@ <a-space :size="12"> <div v-for="time in timeRanges" - :key="time.text" + :key="time" tabindex="0" class="time-range" - :class="{ 'time-range-activated': currTimeRange === time.text }" + :class="{ 'time-range-activated': currTimeRange === time }" @click="handleTimeRange(time)" > - {{ time.text }} + {{ time }} </div> </a-space> </div> <template v-if="noChartData"> <div class="box-empty"> - <a-empty /> + <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" /> </div> </template> <a-row v-else class="box-content"> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <gauge-chart chart-id="chart1" :title="$t('overview.memory_usage')" /> + <gauge-chart + chart-id="chart1" + :percent="chartData?.memoryUsageCur" + :title="$t('overview.memory_usage')" + /> + </div> + </a-col> + <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> + <div class="chart-item-wrp"> + <gauge-chart chart-id="chart2" :percent="chartData?.cpuUsageCur" :title="$t('overview.cpu_usage')" /> </div> </a-col> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <gauge-chart chart-id="chart2" :title="$t('overview.cpu_usage')" /> + <category-chart + chart-id="chart3" + :x-axis-data="chartData?.timestamps" + :data="chartData?.memoryUsage" + :title="$t('overview.memory_usage')" + /> </div> </a-col> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <category-chart chart-id="chart4" :title="$t('overview.cpu_usage')" /> + <category-chart + chart-id="chart4" + :x-axis-data="chartData?.timestamps" + :data="chartData?.cpuUsage" + :title="$t('overview.cpu_usage')" + /> </div> </a-col> <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> <div class="chart-item-wrp"> - <category-chart chart-id="chart3" :title="$t('overview.memory_usage')" /> + <category-chart + chart-id="chart5" + :x-axis-data="chartData?.timestamps" + :data="chartData" + :title="$t('overview.system_load')" + :formatter="{ + tooltip: (val) => `${val == null || val == '' ? '--' : val}`, + yAxis: (val) => `${val}` + }" + :legend-map="[ + ['systemLoad1', 'load1'], + ['systemLoad5', 'load5'], + ['systemLoad15', 'load15'] + ]" + /> + </div> + </a-col> + <a-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12"> + <div class="chart-item-wrp"> + <category-chart + chart-id="chart6" + :x-axis-data="chartData?.timestamps" + :data="chartData" + :title="$t('overview.disk_io')" + :formatter="{ + tooltip: (val) => `${val == null || val == '' ? '--' : formatFromByte(val as number, 0)}`, + yAxis: (val) => formatFromByte(val as number, 0) + }" + :legend-map="[ + ['diskRead', 'read'], + ['diskWrite', 'write'] + ]" + /> </div> </a-col> </a-row> @@ -347,7 +386,7 @@ &-content { border-radius: 8px; - overflow: hidden; + overflow: visible; box-sizing: border-box; border: 1px solid $color-border; } diff --git a/bigtop-manager-ui/src/utils/storage.ts b/bigtop-manager-ui/src/utils/storage.ts index 1d35b5b1..2e32d25e 100644 --- a/bigtop-manager-ui/src/utils/storage.ts +++ b/bigtop-manager-ui/src/utils/storage.ts @@ -17,7 +17,7 @@ * under the License. */ -export const formatFromByte = (value: number): string => { +export const formatFromByte = (value: number, decimals = 2): string => { if (isNaN(value)) { return '' } @@ -25,14 +25,41 @@ export const formatFromByte = (value: number): string => { if (value < 1024) { return `${value} B` } else if (value < 1024 ** 2) { - return `${(value / 1024).toFixed(2)} KB` + return `${(value / 1024).toFixed(decimals)} KB` } else if (value < 1024 ** 3) { - return `${(value / 1024 ** 2).toFixed(2)} MB` + return `${(value / 1024 ** 2).toFixed(decimals)} MB` } else if (value < 1024 ** 4) { - return `${(value / 1024 ** 3).toFixed(2)} GB` + return `${(value / 1024 ** 3).toFixed(decimals)} GB` } else if (value < 1024 ** 5) { - return `${(value / 1024 ** 4).toFixed(2)} TB` + return `${(value / 1024 ** 4).toFixed(decimals)} TB` } else { - return `${(value / 1024 ** 5).toFixed(2)} PB` + return `${(value / 1024 ** 5).toFixed(decimals)} PB` } } + +/** + * Safely rounds a value to a fixed number of decimal places. + * + * @param num - The value to round. + * @param decimals - Decimal places to keep (default: 2). + * @param fallback - Fallback string if value is not finite (default: '0.00'). + * @param preserveEmpty - If true, returns null or '' as-is; otherwise, falls back (default: true). + * @returns Rounded string, fallback, or original empty/null input. + */ +export const roundFixed = ( + num: unknown, + decimals = 2, + fallback = '0.00', + preserveEmpty = true +): string | null | undefined => { + if (preserveEmpty && (num === '' || num === null || num === undefined)) { + return num + } + + const n = Number(num) + if (!isFinite(n)) return fallback + + const factor = 10 ** decimals + const rounded = Math.round((n + Number.EPSILON) * factor) / factor + return rounded.toFixed(decimals) +}