48 lines
9.6 KiB
Vue
48 lines
9.6 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { Monitor, Connection, Warning, Refresh, Download, Cpu, ArrowRight, CircleCheck, Clock } from '@element-plus/icons-vue'
|
||
import { usePlatform } from '../stores/platform'
|
||
import StatusTag from '../components/StatusTag.vue'
|
||
const data = usePlatform(), router = useRouter()
|
||
const active = computed(() => data.terminals.filter(t => t.status === 'online' && t.sessionDesktopId && t.sessionState === 'connected'))
|
||
const averageRTT = computed(() => {
|
||
const samples = active.value.flatMap(t => t.rtt == null ? [] : [t.rtt])
|
||
return samples.length ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length * 10) / 10 : null
|
||
})
|
||
const healthyNodes = computed(() => data.resources.filter(n => n.status === 'online' && Date.now() - Date.parse(n.updatedAt) <= 90000).length)
|
||
const recent = computed(() => data.tasks.slice(0, 5))
|
||
const taskLabels: Record<string, string> = { start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源' }
|
||
const stats = computed(() => [
|
||
{ title: '运行桌面', value: data.overview?.runningDesktops ?? 0, total: `/ ${data.overview?.desktopCount ?? 0}`, caption: '已纳管桌面的实际电源状态', path: '/desktops', icon: Monitor, warning: false },
|
||
{ title: '活跃会话', value: data.overview?.activeSessions ?? 0, total: '个', caption: '终端已确认画面就绪', path: '/sessions', icon: Connection, warning: false },
|
||
{ title: '网络 RTT', value: averageRTT.value ?? '—', total: averageRTT.value === null ? '' : 'ms', caption: averageRTT.value === null ? '当前无活动会话指标' : '活动会话采样均值', path: '/sessions', icon: Clock, warning: false },
|
||
{ title: '异常任务', value: data.overview?.failedTasks ?? 0, total: '个', caption: '最近 24 小时失败或结果未确认', path: '/tasks', icon: Warning, warning: (data.overview?.failedTasks ?? 0) > 0 },
|
||
])
|
||
function exportReport() {
|
||
const lines = [
|
||
['VDI Cloud 运行快照', new Date().toLocaleString('zh-CN')],
|
||
['指标', '数值'], ['运行桌面', String(data.overview?.runningDesktops ?? '')],
|
||
['桌面总数', String(data.overview?.desktopCount ?? '')], ['在线终端', String(data.overview?.onlineTerminals ?? '')],
|
||
['终端总数', String(data.overview?.terminalCount ?? '')], ['活动连接', String(data.overview?.activeSessions ?? '')],
|
||
['网络 RTT(ms)', averageRTT.value === null ? '暂无数据' : String(averageRTT.value)],
|
||
['最近24小时异常任务', String(data.overview?.failedTasks ?? '')],
|
||
]
|
||
const url = URL.createObjectURL(new Blob(['\ufeff' + lines.map(row => row.map(v => `"${v.replace(/"/g, '""')}"`).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8;' }))
|
||
const link = document.createElement('a'); link.href = url; link.download = `vdi-overview-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||
}
|
||
</script>
|
||
<template>
|
||
<div class="page-heading"><div><h1>运行总览</h1><p class="subtext">云桌面、终端连接与资源健康,一处掌握。</p></div><div class="actions"><el-button @click="exportReport"><el-icon><Download /></el-icon><span>导出快照</span></el-button><el-button type="primary" @click="data.refresh"><el-icon><Refresh /></el-icon><span>刷新数据</span></el-button></div></div>
|
||
<div class="stats"><button v-for="stat in stats" :key="stat.title" class="stat" @click="router.push(stat.path)"><div class="stat-label">{{ stat.title }}<el-icon :size="18"><component :is="stat.icon" /></el-icon></div><strong :class="{ 'tone-warn': stat.warning }">{{ stat.value }}<span class="stat-unit">{{ stat.total }}</span></strong><div class="stat-caption">{{ stat.caption }}</div></button></div>
|
||
<div class="two-columns overview-grid">
|
||
<section class="panel nodes-panel"><div class="panel-heading"><div><h2>计算资源水位</h2><p class="subtext">当前节点负载 · 每 30 秒同步 PVE</p></div><router-link to="/resources">资源详情 <el-icon><ArrowRight /></el-icon></router-link></div><div v-if="!data.resources.length" class="empty-panel"><div><div class="empty-icon"><el-icon :size="27"><Cpu /></el-icon></div><h3>尚未同步计算资源</h3><p class="subtext">接入 Proxmox 后查看节点使用情况</p></div></div><div v-for="node in data.resources" :key="node.id" class="node-block"><div class="node-heading"><div class="node-name"><div class="node-icon"><el-icon :size="21"><Cpu /></el-icon></div><div><strong>{{ node.name }}</strong><small>{{ node.provider === 'demo' ? '演示节点' : 'Proxmox 计算节点' }}</small></div></div><el-tag v-if="Date.now() - Date.parse(node.updatedAt) > 90000 && node.provider !== 'demo'" type="warning">数据过期</el-tag><StatusTag v-else :value="node.status" /></div><div class="node-meters"><div v-for="meter in [{ label: 'CPU 使用率', value: node.cpu }, { label: '内存使用率', value: node.memory }, { label: '存储使用率', value: node.storage }]" :key="meter.label"><div class="meter-label"><span>{{ meter.label }}</span><strong>{{ Math.min(100, Math.round(meter.value * 100)) }}<small>%</small></strong></div><el-progress :percentage="Math.min(100, Math.round(meter.value * 100))" :show-text="false" :stroke-width="8" :color="meter.value >= .85 ? 'var(--vdi-warn)' : 'var(--vdi-brand)'" /></div></div><p class="secondary node-sync">最近同步 {{ new Date(node.updatedAt).toLocaleString('zh-CN') }}</p></div></section>
|
||
<section class="panel health-panel"><div class="panel-heading"><h2>资源健康度</h2><span class="secondary">当前快照</span></div><div class="health-row"><span><i class="health-dot" :class="healthyNodes === data.resources.length && data.resources.length ? 'tone-ok' : 'tone-warn'" />计算节点</span><strong>{{ healthyNodes }} / {{ data.resources.length }}</strong></div><div class="health-row"><span><i class="health-dot" :class="data.overview?.onlineTerminals ? 'tone-ok' : 'tone-warn'" />在线终端</span><router-link to="/terminals">{{ data.overview?.onlineTerminals ?? 0 }} / {{ data.overview?.terminalCount ?? 0 }}</router-link></div><div class="health-row"><span><i class="health-dot" :class="data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length ? 'tone-warn' : 'tone-ok'" />未授权桌面</span><router-link to="/users">{{ data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length }} 台</router-link></div><div class="health-row"><span><i class="health-dot tone-ok" />当前账号</span><span class="tone-ok"><el-icon><CircleCheck /></el-icon> 已认证</span></div><router-link to="/tasks" class="pending-summary"><el-icon :size="19"><Clock /></el-icon><div><strong>{{ data.tasks.filter(t => ['queued', 'running'].includes(t.status)).length }} 个任务等待回执</strong><small>查看任务进度与执行结果</small></div><el-icon><ArrowRight /></el-icon></router-link></section>
|
||
</div>
|
||
<section class="panel"><div class="panel-heading"><div><h2>最近任务</h2><p class="subtext">每次操作都有进度和结果记录</p></div><router-link to="/tasks">查看全部 <el-icon><ArrowRight /></el-icon></router-link></div><el-table :data="recent" empty-text="尚无执行任务"><el-table-column prop="targetName" label="操作目标" min-width="150" /><el-table-column label="操作" min-width="120"><template #default="{ row }">{{ taskLabels[row.action] || row.action }}</template></el-table-column><el-table-column label="状态" width="125"><template #default="{ row }"><StatusTag :value="row.status === 'running' ? '执行中' : row.status" /></template></el-table-column><el-table-column prop="message" label="执行结果" min-width="250" /></el-table></section>
|
||
</template>
|
||
<style scoped>
|
||
.actions .el-icon{margin-right:7px}.stat-unit{color:var(--vdi-muted);font-size:14px;font-weight:500}.node-block+.node-block{border-top:1px solid var(--vdi-border);padding-top:22px;margin-top:22px}.node-heading{display:flex;justify-content:space-between;align-items:center;margin:24px 0 27px}.node-name{display:flex;align-items:center;gap:12px}.node-name strong{font-size:15px}.node-name small{display:block;color:var(--vdi-muted);font-size:11px;margin-top:4px}.node-icon{display:grid;place-items:center;width:42px;height:42px;background:var(--vdi-panel-2);border-radius:12px;color:var(--vdi-accent)}.node-meters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}.meter-label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:16px}.meter-label>span{font-size:12px;color:var(--vdi-muted)}.meter-label strong{font-size:18px}.meter-label strong small{font-size:11px;font-weight:400;margin-left:2px}.node-sync{margin-top:29px}.health-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 0;border-bottom:1px solid var(--vdi-border);font-size:13px}.health-row>span:first-child{display:flex;align-items:center}.health-row strong{font-size:14px}.health-row .tone-ok{display:flex;align-items:center;gap:5px}.pending-summary{display:flex;align-items:center;gap:12px;background:var(--vdi-panel-2);border-radius:12px;padding:15px;margin-top:21px;color:var(--vdi-text)}.pending-summary>.el-icon:first-child{color:var(--vdi-accent)}.pending-summary>div{flex:1}.pending-summary strong{font-size:12px;font-weight:500}.pending-summary small{display:block;font-size:11px;color:var(--vdi-muted);margin-top:5px}.panel-heading>a{display:flex;gap:4px;align-items:center}
|
||
@media(max-width:600px){.node-meters{grid-template-columns:1fr;gap:20px}.node-heading{margin-top:18px}.node-sync{margin-top:20px}.meter-label{margin-bottom:10px}.overview-grid{gap:0}}
|
||
</style>
|