2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 09:13:49 +08:00
Files
panel/web/src/views/dashboard/IndexView.vue
2024-12-03 21:14:27 +08:00

820 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
defineOptions({
name: 'dashboard-index'
})
import { LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent
} from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { NButton, NPopconfirm } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import dashboard from '@/api/panel/dashboard'
import { router } from '@/router'
import { useTabStore } from '@/store'
import { formatDateTime, formatDuration, toTimestamp } from '@/utils/common'
import { formatBytes, formatPercent } from '@/utils/file'
import VChart from 'vue-echarts'
import type { CountInfo, HomeApp, Realtime, SystemInfo } from './types'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent
])
const { locale } = useI18n()
const tabStore = useTabStore()
const realtime = ref<Realtime | null>(null)
const systemInfo = ref<SystemInfo | null>(null)
const homeApps = ref<HomeApp[] | null>(null)
const homeAppsLoading = ref(true)
const countInfo = ref<CountInfo>({
website: 0,
database: 0,
ftp: 0,
cron: 0
})
const nets = ref<Array<string>>([]) // 选择的网卡
const disks = ref<Array<string>>([]) // 选择的硬盘
const chartType = ref('net')
const unitType = ref('KB')
const units = [
{ label: 'B', value: 'B' },
{ label: 'KB', value: 'KB' },
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' }
]
const cores = ref(0)
const diskReadBytes = ref<Array<number>>([])
const diskWriteBytes = ref<Array<number>>([])
const netBytesSent = ref<Array<number>>([])
const netBytesRecv = ref<Array<number>>([])
const timeDiskData = ref<Array<string>>([])
const timeNetData = ref<Array<string>>([])
const total = reactive({
diskReadBytes: 0,
diskWriteBytes: 0,
diskRWBytes: 0,
diskRWTime: 0,
netBytesSent: 0,
netBytesRecv: 0
})
const current = reactive({
diskReadBytes: 0,
diskWriteBytes: 0,
diskRWBytes: 0,
diskRWTime: 0,
netBytesSent: 0,
netBytesRecv: 0,
time: 0
})
const statusColor = (percentage: number) => {
if (percentage >= 90) {
return 'var(--error-color)'
} else if (percentage >= 80) {
return 'var(--warning-color)'
} else if (percentage >= 70) {
return 'var(--info-color)'
}
return 'var(--success-color)'
}
const statusText = (percentage: number) => {
if (percentage >= 90) {
return '运行堵塞'
} else if (percentage >= 80) {
return '运行缓慢'
} else if (percentage >= 70) {
return '运行正常'
}
return '运行流畅'
}
const chartOptions = computed(() => {
return {
title: {
text: chartType.value == 'net' ? '网络' : '硬盘',
textAlign: 'left',
textStyle: {
fontSize: 20
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: function (params: any) {
let res = params[0].name + '<br/>'
params.forEach(function (item: any) {
res += `${item.marker} ${item.seriesName}: ${item.value} ${unitType.value}<br/>`
})
return res
}
},
legend: {
align: 'left',
data: chartType.value == 'net' ? ['发送', '接收'] : ['读取', '写入']
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeDiskData.value
},
yAxis: {
name: `单位 ${unitType.value}`,
type: 'value',
axisLabel: {
formatter: `{value} ${unitType.value}`
}
},
series: [
{
name: chartType.value == 'net' ? '发送' : '读取',
type: 'line',
smooth: true,
data: chartType.value == 'net' ? netBytesSent.value : diskReadBytes.value,
markPoint: {
data: [
{ type: 'max', name: '最大值' },
{ type: 'min', name: '最小值' }
]
},
markLine: {
data: [{ type: 'average', name: '平均值' }]
},
lineStyle: {
color: 'rgb(247, 184, 81)'
},
itemStyle: {
color: 'rgb(247, 184, 81)'
},
areaStyle: {
color: 'rgb(247, 184, 81)'
}
},
{
name: chartType.value == 'net' ? '接收' : '写入',
type: 'line',
smooth: true,
data: chartType.value == 'net' ? netBytesRecv.value : diskWriteBytes.value,
markPoint: {
data: [
{ type: 'max', name: '最大值' },
{ type: 'min', name: '最小值' }
]
},
markLine: {
data: [{ type: 'average', name: '平均值' }]
},
lineStyle: {
color: 'rgb(82, 169, 255)'
},
itemStyle: {
color: 'rgb(82, 169, 255)'
},
areaStyle: {
color: 'rgb(82, 169, 255)'
}
}
]
}
})
let isFetching = false
const fetchCurrent = async () => {
if (isFetching) return
isFetching = true
dashboard
.current(nets.value, disks.value)
.then(({ data }) => {
data.percent = formatPercent(data.percent)
data.mem.usedPercent = formatPercent(data.mem.usedPercent)
// 计算 CPU 核心数
if (cores.value == 0) {
for (let i = 0; i < data.cpus.length; i++) {
cores.value += data.cpus[i].cores
}
}
// 计算实时数据
const time = current.time == 0 ? 3 : toTimestamp(data.time) - current.time
let netTotalSentTemp = 0
let netTotalRecvTemp = 0
for (let i = 0; i < data.net.length; i++) {
if (data.net[i].name === 'lo') {
continue
}
netTotalSentTemp += data.net[i].bytesSent
netTotalRecvTemp += data.net[i].bytesRecv
}
current.netBytesSent =
total.netBytesSent != 0 ? (netTotalSentTemp - total.netBytesSent) / time : 0
current.netBytesRecv =
total.netBytesRecv != 0 ? (netTotalRecvTemp - total.netBytesRecv) / time : 0
total.netBytesSent = netTotalSentTemp
total.netBytesRecv = netTotalRecvTemp
// 计算硬盘读写
let diskTotalReadTemp = 0
let diskTotalWriteTemp = 0
let diskRWTimeTemp = 0
for (let i = 0; i < data.disk_io.length; i++) {
diskTotalReadTemp += data.disk_io[i].readBytes
diskTotalWriteTemp += data.disk_io[i].writeBytes
diskRWTimeTemp += data.disk_io[i].readTime + data.disk_io[i].writeTime
}
current.diskReadBytes =
total.diskReadBytes != 0 ? (diskTotalReadTemp - total.diskReadBytes) / time : 0
current.diskWriteBytes =
total.diskWriteBytes != 0 ? (diskTotalWriteTemp - total.diskWriteBytes) / time : 0
current.diskRWBytes =
total.diskRWBytes != 0
? (diskTotalReadTemp + diskTotalWriteTemp - total.diskRWBytes) / time
: 0
current.diskRWTime =
total.diskRWTime != 0 ? Number(((diskRWTimeTemp - total.diskRWTime) / time).toFixed(2)) : 0
current.time = toTimestamp(data.time)
total.diskReadBytes = diskTotalReadTemp
total.diskWriteBytes = diskTotalWriteTemp
total.diskRWBytes = diskTotalReadTemp + diskTotalWriteTemp
total.diskRWTime = diskRWTimeTemp
// 图表数据填充
netBytesSent.value.push(calculateSize(current.netBytesSent))
if (netBytesSent.value.length > 10) {
netBytesSent.value.splice(0, 1)
}
netBytesRecv.value.push(calculateSize(current.netBytesRecv))
if (netBytesRecv.value.length > 10) {
netBytesRecv.value.splice(0, 1)
}
diskReadBytes.value.push(calculateSize(current.diskReadBytes))
if (diskReadBytes.value.length > 10) {
diskReadBytes.value.splice(0, 1)
}
diskWriteBytes.value.push(calculateSize(current.diskWriteBytes))
if (diskWriteBytes.value.length > 10) {
diskWriteBytes.value.splice(0, 1)
}
timeDiskData.value.push(formatDateTime(data.time))
if (timeDiskData.value.length > 10) {
timeDiskData.value.splice(0, 1)
}
timeNetData.value.push(formatDateTime(data.time))
if (timeNetData.value.length > 10) {
timeNetData.value.splice(0, 1)
}
realtime.value = data
})
.finally(() => {
isFetching = false
})
}
const fetchSystemInfo = async () => {
dashboard.systemInfo().then((res) => {
systemInfo.value = res.data
})
}
const fetchCountInfo = async () => {
dashboard.countInfo().then((res) => {
countInfo.value = res.data
})
}
const fetchHomeApps = async () => {
homeAppsLoading.value = true
dashboard.homeApps().then((res) => {
homeApps.value = res.data
homeAppsLoading.value = false
})
}
const handleRestartPanel = () => {
clearInterval(homeInterval)
window.$message.loading('面板重启中...')
dashboard.restart().then(() => {
window.$message.success('面板重启成功')
setTimeout(() => {
tabStore.reloadTab(tabStore.active)
}, 3000)
})
}
const handleUpdate = () => {
dashboard.checkUpdate().then((res) => {
if (res.data.update) {
router.push({ name: 'dashboard-update' })
} else {
window.$message.success('当前已是最新版本')
}
})
}
const toSponsor = () => {
window.open('https://afdian.com/a/TheTNB')
}
const handleManageApp = (slug: string) => {
router.push({ name: 'apps-' + slug + '-index' })
}
const calculateSize = (bytes: any) => {
switch (unitType.value) {
case 'B':
return Number(bytes.toFixed(2))
case 'KB':
return Number((bytes / 1024).toFixed(2))
case 'MB':
return Number((bytes / 1024 / 1024).toFixed(2))
case 'GB':
return Number((bytes / 1024 / 1024 / 1024).toFixed(2))
default:
return 0
}
}
const clearCurrent = () => {
total.netBytesSent = 0
total.netBytesRecv = 0
total.diskReadBytes = 0
total.diskWriteBytes = 0
total.diskRWBytes = 0
total.diskRWTime = 0
netBytesSent.value = []
netBytesRecv.value = []
timeNetData.value = []
diskReadBytes.value = []
diskWriteBytes.value = []
timeDiskData.value = []
}
const quantifier = computed(() => {
return locale.value === 'en' ? '' : ' 个'
})
let homeInterval: any = null
onMounted(() => {
fetchCurrent()
fetchSystemInfo()
fetchCountInfo()
fetchHomeApps()
homeInterval = setInterval(() => {
fetchCurrent()
}, 3000)
})
onUnmounted(() => {
clearInterval(homeInterval)
})
if (import.meta.hot) {
import.meta.hot.accept()
import.meta.hot.dispose(() => {
clearInterval(homeInterval)
})
}
</script>
<template>
<AppPage :show-footer="true" min-w-375>
<div flex-1>
<n-space vertical>
<n-card :segmented="true" rounded-10 size="small">
<n-page-header :subtitle="systemInfo?.panel_version">
<n-grid :cols="4" pb-10>
<n-gi>
<n-statistic label="网站" :value="countInfo.website + quantifier" />
</n-gi>
<n-gi>
<n-statistic label="数据库" :value="countInfo.database + quantifier" />
</n-gi>
<n-gi>
<n-statistic label="FTP" :value="countInfo.ftp + quantifier" />
</n-gi>
<n-gi>
<n-statistic label="计划任务" :value="countInfo.cron + quantifier" />
</n-gi>
</n-grid>
<template #title>耗子面板</template>
<template #extra>
<n-flex>
<n-button type="primary" @click="toSponsor"> 赞助支持 </n-button>
<n-popconfirm @positive-click="handleRestartPanel">
<template #trigger>
<n-button type="warning"> 重启 </n-button>
</template>
确定要重启面板吗
</n-popconfirm>
<n-button type="success" @click="handleUpdate"> 更新 </n-button>
</n-flex>
</template>
</n-page-header>
</n-card>
<n-card :segmented="true" rounded-10 size="small" title="资源总览">
<n-flex v-if="realtime" size="large">
<n-popover trigger="hover">
<template #trigger>
<n-flex vertical flex items-center p-20 pl-40 pr-40>
<p>负载状态</p>
<n-progress
type="dashboard"
:percentage="Math.round(formatPercent((realtime.load.load1 / cores) * 100))"
:color="statusColor((realtime.load.load1 / cores) * 100)"
>
</n-progress>
<p>{{ statusText((realtime.load.load1 / cores) * 100) }}</p>
</n-flex>
</template>
<n-table :single-line="false" striped>
<tr>
<th>最近 1 分钟</th>
<td>
{{ formatPercent((realtime.load.load1 / cores) * 100) }}% /
{{ realtime.load.load1 }}
</td>
</tr>
<tr>
<th>最近 5 分钟</th>
<td>
{{ formatPercent((realtime.load.load5 / cores) * 100) }}% /
{{ realtime.load.load5 }}
</td>
</tr>
<tr>
<th>最近 15 分钟</th>
<td>
{{ formatPercent((realtime.load.load15 / cores) * 100) }}% /
{{ realtime.load.load15 }}
</td>
</tr>
</n-table>
</n-popover>
<n-popover trigger="hover">
<template #trigger>
<n-flex vertical flex items-center p-20 pl-40 pr-40>
<p>CPU</p>
<n-progress
type="dashboard"
:percentage="realtime.percent"
:color="statusColor(realtime.percent)"
>
</n-progress>
<p>{{ cores }} 核心</p>
</n-flex>
</template>
<n-table :single-line="false" striped>
<tr>
<th>型号</th>
<td>{{ realtime.cpus[0].modelName }}</td>
</tr>
<tr>
<th>参数</th>
<td>
{{ realtime.cpus.length }} CPU {{ cores }} 核心
{{ formatBytes(realtime.cpus[0].cacheSize * 1024) }} 缓存
</td>
</tr>
<tr v-for="item in realtime.cpus" :key="item.modelName">
<th>CPU-{{ item.cpu }}</th>
<td>
使用率 {{ formatPercent(realtime.percents[item.cpu]) }}% 频率 {{ item.mhz }} MHz
</td>
</tr>
</n-table>
</n-popover>
<n-popover trigger="hover">
<template #trigger>
<n-flex vertical flex items-center p-20 pl-40 pr-40>
<p>内存</p>
<n-progress
type="dashboard"
:percentage="realtime.mem.usedPercent"
:color="statusColor(realtime.mem.usedPercent)"
>
</n-progress>
<p>{{ formatBytes(realtime.mem.total) }}</p>
</n-flex>
</template>
<n-table :single-line="false" striped>
<tr>
<th>活跃</th>
<td>
{{ formatBytes(realtime.mem.active) }}
</td>
</tr>
<tr>
<th>不活跃</th>
<td>
{{ formatBytes(realtime.mem.inactive) }}
</td>
</tr>
<tr>
<th>空闲</th>
<td>
{{ formatBytes(realtime.mem.free) }}
</td>
</tr>
<tr>
<th>共享</th>
<td>
{{ formatBytes(realtime.mem.shared) }}
</td>
</tr>
<tr>
<th>已提交</th>
<td>
{{ formatBytes(realtime.mem.committedas) }}
</td>
</tr>
<tr>
<th>提交限制</th>
<td>
{{ formatBytes(realtime.mem.commitlimit) }}
</td>
</tr>
<tr>
<th>SWAP大小</th>
<td>
{{ formatBytes(realtime.mem.swaptotal) }}
</td>
</tr>
<tr>
<th>SWAP已用</th>
<td>
{{ formatBytes(realtime.mem.swapcached) }}
</td>
</tr>
<tr>
<th>SWAP可用</th>
<td>
{{ formatBytes(realtime.mem.swapfree) }}
</td>
</tr>
<tr>
<th>物理内存大小</th>
<td>
{{ formatBytes(realtime.mem.total) }}
</td>
</tr>
<tr>
<th>物理内存已用</th>
<td>
{{ formatBytes(realtime.mem.used) }}
</td>
</tr>
<tr>
<th>物理内存可用</th>
<td>
{{ formatBytes(realtime.mem.available) }}
</td>
</tr>
<tr>
<th>buffers/cached</th>
<td>
{{ formatBytes(realtime.mem.buffers) }} / {{ formatBytes(realtime.mem.cached) }}
</td>
</tr>
</n-table>
</n-popover>
<n-popover v-for="item in realtime.disk_usage" :key="item.path" trigger="hover">
<template #trigger>
<n-flex vertical flex items-center p-20 pl-40 pr-40>
<p>{{ item.path }}</p>
<n-progress
type="dashboard"
:percentage="formatPercent(item.usedPercent)"
:color="statusColor(item.usedPercent)"
>
</n-progress>
<p>{{ formatBytes(item.used) }} / {{ formatBytes(item.total) }}</p>
</n-flex>
</template>
<n-table :single-line="false">
<tr>
<th>挂载点</th>
<td>{{ item.path }}</td>
</tr>
<tr>
<th>文件系统</th>
<td>{{ item.fstype }}</td>
</tr>
<tr>
<th>Inodes 使用率</th>
<td>{{ formatPercent(item.inodesUsedPercent) }}%</td>
</tr>
<tr>
<th>Inodes 总数</th>
<td>{{ item.inodesTotal }}</td>
</tr>
<tr>
<th>Inodes 已用</th>
<td>{{ item.inodesUsed }}</td>
</tr>
<tr>
<th>Inodes 可用</th>
<td>{{ item.inodesFree }}</td>
</tr>
</n-table>
</n-popover>
</n-flex>
<n-skeleton v-else text :repeat="10" />
</n-card>
<n-grid
x-gap="12"
y-gap="12"
cols="1 s:1 m:1 l:2 xl:2 2xl:2"
item-responsive
responsive="screen"
>
<n-gi>
<n-flex vertical>
<n-card :segmented="true" size="small" title="快捷应用" min-h-340 rounded-10>
<n-scrollbar max-h-270>
<n-grid
v-if="!homeAppsLoading"
x-gap="12"
y-gap="12"
cols="3 s:1 m:2 l:3"
item-responsive
responsive="screen"
>
<n-gi v-for="item in homeApps" :key="item.name">
<n-card
:segmented="true"
size="small"
cursor-pointer
rounded-10
hover:card-shadow
@click="handleManageApp(item.slug)"
>
<n-space>
<n-thing>
<template #avatar>
<div class="mt-8">
<TheIcon
:size="30"
:icon="item.icon"
color="var(--primary-color)"
/>
</div>
</template>
<template #header>
{{ item.name }}
</template>
<template #description>
{{ item.version }}
</template>
</n-thing>
</n-space>
</n-card>
</n-gi>
</n-grid>
</n-scrollbar>
<n-text v-if="!homeAppsLoading && !homeApps">
您还没有设置任何应用在此显示
</n-text>
<n-skeleton v-if="homeAppsLoading" text :repeat="12" />
</n-card>
<n-card :segmented="true" rounded-10 size="small" title="环境信息">
<n-table v-if="systemInfo" :single-line="false">
<tr>
<th>系统主机名</th>
<td>
{{ systemInfo?.hostname || '加载中...' }}
</td>
</tr>
<tr>
<th>系统版本号</th>
<td>
{{ `${systemInfo?.os_name} ${systemInfo?.kernel_arch}` || '加载中...' }}
</td>
</tr>
<tr>
<th>系统内核版本</th>
<td>
{{ systemInfo?.kernel_version || '加载中...' }}
</td>
</tr>
<tr>
<th>系统运行时间</th>
<td>
{{ formatDuration(Number(systemInfo?.uptime)) || '加载中...' }}
</td>
</tr>
<tr>
<th>面板内部版本</th>
<td>
{{
systemInfo?.commit_hash +
' ' +
systemInfo?.go_version +
' ' +
systemInfo?.build_time || '加载中...'
}}
</td>
</tr>
<tr>
<th>面板编译信息</th>
<td>
{{
systemInfo?.build_id +
' ' +
systemInfo?.build_user +
'/' +
systemInfo?.build_host || '加载中...'
}}
</td>
</tr>
</n-table>
<n-skeleton v-else text :repeat="9" />
</n-card>
</n-flex>
</n-gi>
<n-gi>
<n-card :segmented="true" rounded-10 size="small" title="实时监控">
<n-flex vertical v-if="systemInfo">
<n-form
inline
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
>
<n-form-item>
<n-radio-group v-model:value="chartType">
<n-radio-button value="net" label="网络" />
<n-radio-button value="disk" label="硬盘" />
</n-radio-group>
</n-form-item>
<n-form-item label="单位" ml-auto>
<n-select
v-model:value="unitType"
:options="units"
@update-value="clearCurrent"
w-80
></n-select>
</n-form-item>
<n-form-item v-if="chartType == 'net'" label="网卡">
<n-select
multiple
v-model:value="nets"
:options="systemInfo.nets"
@update-value="clearCurrent"
w-200
></n-select>
</n-form-item>
<n-form-item v-if="chartType == 'disk'" label="硬盘">
<n-select
multiple
v-model:value="disks"
:options="systemInfo.disks"
@update-value="clearCurrent"
w-200
></n-select>
</n-form-item>
</n-form>
<n-flex v-if="chartType == 'net'">
<n-tag>总发送 {{ formatBytes(total.netBytesSent) }}</n-tag>
<n-tag>总接收 {{ formatBytes(total.netBytesRecv) }}</n-tag>
<n-tag>实时发送 {{ formatBytes(current.netBytesSent) }}/s</n-tag>
<n-tag>实时接收 {{ formatBytes(current.netBytesRecv) }}/s</n-tag>
</n-flex>
<n-flex v-if="chartType == 'disk'">
<n-tag>读取 {{ formatBytes(total.diskReadBytes) }}</n-tag>
<n-tag>写入 {{ formatBytes(total.diskWriteBytes) }}</n-tag>
<n-tag>实时读写 {{ formatBytes(current.diskRWBytes) }}/s</n-tag>
<n-tag>读写延迟 {{ current.diskRWTime }}ms</n-tag>
</n-flex>
<n-card :bordered="false" h-530 pt-10>
<v-chart class="chart" :option="chartOptions" autoresize />
</n-card>
</n-flex>
<n-skeleton v-else text :repeat="25" />
</n-card>
</n-gi>
</n-grid>
</n-space>
</div>
</AppPage>
</template>