feat: 硬件看板展示 GPU 进程

HardwareView 增加显卡进程占用列表展示,配套回归脚本与 npm test:hardware 脚本注册。
This commit is contained in:
caoxiaozhu
2026-07-13 10:31:29 +08:00
parent 0d82930181
commit 6b48b8fac3
3 changed files with 585 additions and 235 deletions

View File

@@ -17,6 +17,7 @@
"test:eval-create": "node scripts/regression-eval-create-wizard.mjs",
"test:eval-detail": "node scripts/regression-eval-detail.mjs",
"test:model-manage": "node scripts/regression-model-manage.mjs",
"test:hardware": "node scripts/regression-hardware-dashboard.mjs",
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs",
"test:page-surface": "node scripts/regression-page-surface.mjs"
},

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const sourceRoot = path.resolve(scriptDir, '../src')
const [viewSource, typeSource, mockSource] = await Promise.all([
readFile(path.join(sourceRoot, 'views/system/HardwareView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'types/index.ts'), 'utf8'),
readFile(path.join(sourceRoot, 'mock/data.ts'), 'utf8'),
])
assert.match(viewSource, /title="平台性能"/, '平台性能页缺少明确标题')
assert.match(viewSource, /硬件资源概览/, '平台性能页缺少硬件资源概览')
assert.match(viewSource, /系统资源趋势/, '平台性能页缺少资源趋势')
assert.match(viewSource, /GPU 资源池/, '平台性能页缺少 GPU 资源池')
assert.match(viewSource, /gpu-detail-drawer/, 'GPU 卡片缺少详情抽屉')
assert.match(viewSource, /GPU 进程/, 'GPU 详情缺少占用进程表')
assert.match(viewSource, /visibilitychange/, '轮询没有在页面不可见时暂停')
assert.match(viewSource, /requesting \|\| disposed/, '轮询缺少防重入保护')
assert.match(viewSource, /slice\(-60\)/, '趋势快照没有限制为最近 60 个采样点')
assert.match(viewSource, /@keydown="handleGpuKeydown/, 'GPU 卡片缺少键盘访问能力')
assert.match(viewSource, /download_mb_s/, '网络概览没有使用明确的实时速率字段')
assert.doesNotMatch(
viewSource,
/download_mb_s\s*\?\?\s*info\.network\?\.download_mb/,
'不能把累计网络流量回退显示为实时速率',
)
assert.match(typeSource, /export interface GpuProcess/, '系统监控类型缺少 GPU 进程结构')
assert.match(typeSource, /processes\?: GpuProcess\[\]/, 'GPU 类型缺少进程详情')
assert.match(typeSource, /download_mb_s\?: number/, '系统类型缺少实时网络接收速率')
assert.match(typeSource, /power_limit_w\?: number/, 'GPU 类型缺少功耗上限')
for (let id = 0; id < 8; id += 1) {
assert.match(mockSource, new RegExp(`id:\\s*${id},[\\s\\S]*?uuid:\\s*'GPU-MOCK-A800-0${id}'`), `Mock 缺少 GPU ${id} 详情`)
}
console.log('平台性能页回归检查通过')

View File

@@ -1,283 +1,591 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import PageCard from '@/components/PageCard.vue'
import { getSystemInfo } from '@/api/modules/system'
import type { SystemInfo } from '@/types'
import type { GpuInfo, SystemInfo } from '@/types'
const refreshInterval = ref(5000)
let timer: ReturnType<typeof setInterval> | null = null
interface Snapshot {
time: string
cpu: number
memory: number
disk: number
gpuAverage: number
gpu: Array<{ usage: number; memory: number; temperature: number }>
}
const info = ref<SystemInfo>({})
const loading = ref(true)
const refreshing = ref(false)
const loadError = ref('')
const lastUpdated = ref<Date | null>(null)
const autoRefresh = ref(true)
const refreshInterval = ref(5000)
const snapshots = ref<Snapshot[]>([])
const drawerVisible = ref(false)
const selectedGpuIndex = ref(0)
async function fetchInfo() {
try {
info.value = await getSystemInfo()
} catch {
// ignore
let timer: ReturnType<typeof setTimeout> | null = null
let disposed = false
let requesting = false
const gpus = computed(() => info.value.gpu ?? [])
const selectedGpu = computed(() => gpus.value[selectedGpuIndex.value])
const gpuAverage = computed(() => {
if (!gpus.value.length) return 0
return Math.round(gpus.value.reduce((sum, gpu) => sum + safePercent(gpu.gpu_percent), 0) / gpus.value.length)
})
const gpuMemoryTotal = computed(() =>
gpus.value.reduce((sum, gpu) => sum + Number(gpu.memory_total_gb || 0), 0),
)
const gpuMemoryUsed = computed(() =>
gpus.value.reduce((sum, gpu) => sum + Number(gpu.memory_used_gb || 0), 0),
)
const busyGpuCount = computed(() =>
gpus.value.filter((gpu) => gpuStatus(gpu).key === 'busy').length,
)
const systemHealth = computed(() => {
const hasOffline = gpus.value.some((gpu) => gpuStatus(gpu).key === 'offline')
const hasWarning =
safePercent(info.value.cpu?.percent) >= 90 ||
safePercent(info.value.memory?.percent) >= 90 ||
safePercent(info.value.disk?.percent) >= 90 ||
gpus.value.some((gpu) => gpuStatus(gpu).key === 'warning')
if (hasOffline) return { label: '存在离线设备', className: 'is-danger' }
if (hasWarning) return { label: '资源需要关注', className: 'is-warning' }
return { label: '平台运行正常', className: 'is-healthy' }
})
const resourceChartOption = computed(() => ({
animationDuration: 300,
color: ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => `${value}%` },
legend: { top: 0, right: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: '#606266' } },
grid: { left: 14, right: 14, top: 42, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: snapshots.value.map((item) => item.time),
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#909399', hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%', color: '#909399' },
splitLine: { lineStyle: { color: '#ebeef5', type: 'dashed' } },
},
series: [
chartSeries('CPU', snapshots.value.map((item) => item.cpu)),
chartSeries('内存', snapshots.value.map((item) => item.memory)),
chartSeries('磁盘', snapshots.value.map((item) => item.disk)),
chartSeries('GPU 平均', snapshots.value.map((item) => item.gpuAverage)),
],
}))
const gpuChartOption = computed(() => {
const records = snapshots.value
.map((item) => ({ time: item.time, gpu: item.gpu[selectedGpuIndex.value] }))
.filter((item) => item.gpu)
return {
animationDuration: 300,
color: ['#4f46e5', '#0ea5e9', '#f59e0b'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => `${value}%` },
legend: { top: 0, right: 0, itemWidth: 10, itemHeight: 10 },
grid: { left: 12, right: 12, top: 40, bottom: 6, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: records.map((item) => item.time),
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#909399', hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%', color: '#909399' },
splitLine: { lineStyle: { color: '#ebeef5', type: 'dashed' } },
},
series: [
chartSeries('利用率', records.map((item) => item.gpu.usage)),
chartSeries('显存', records.map((item) => item.gpu.memory)),
],
}
})
function chartSeries(name: string, data: number[]) {
return {
name,
type: 'line',
data,
showSymbol: false,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.05 },
}
}
function startTimer() {
stopTimer()
timer = setInterval(fetchInfo, refreshInterval.value)
function safePercent(value?: number) {
return Math.min(100, Math.max(0, Number(value || 0)))
}
function stopTimer() {
function memoryPercent(gpu?: GpuInfo) {
if (!gpu) return 0
if (gpu.memory_percent != null) return safePercent(gpu.memory_percent)
if (!gpu.memory_total_gb) return 0
return Math.round((gpu.memory_used_gb / gpu.memory_total_gb) * 100)
}
function gpuStatus(gpu: GpuInfo) {
if (gpu.status === 'offline') return { key: 'offline', label: '离线', className: 'is-danger' }
if (gpu.status === 'warning' || gpu.temperature >= 80 || memoryPercent(gpu) >= 90) {
return { key: 'warning', label: '需关注', className: 'is-warning' }
}
if (gpu.status === 'busy' || gpu.gpu_percent >= 10) {
return { key: 'busy', label: '运行中', className: 'is-busy' }
}
return { key: 'idle', label: '空闲', className: 'is-idle' }
}
function progressColor(value: number) {
if (value >= 90) return '#f56c6c'
if (value >= 75) return '#e6a23c'
return '#4f46e5'
}
function recordSnapshot(data: SystemInfo) {
const gpuList = data.gpu ?? []
const average = gpuList.length
? Math.round(gpuList.reduce((sum, gpu) => sum + safePercent(gpu.gpu_percent), 0) / gpuList.length)
: 0
const now = new Date()
snapshots.value = [
...snapshots.value,
{
time: now.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }),
cpu: safePercent(data.cpu?.percent),
memory: safePercent(data.memory?.percent),
disk: safePercent(data.disk?.percent),
gpuAverage: average,
gpu: gpuList.map((gpu) => ({
usage: safePercent(gpu.gpu_percent),
memory: memoryPercent(gpu),
temperature: safePercent(gpu.temperature),
})),
},
].slice(-60)
}
async function fetchInfo() {
if (requesting || disposed) return
requesting = true
refreshing.value = true
clearTimer()
try {
const data = await getSystemInfo()
info.value = data
lastUpdated.value = new Date()
loadError.value = ''
recordSnapshot(data)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '监控数据更新失败,请稍后重试'
} finally {
loading.value = false
refreshing.value = false
requesting = false
scheduleNext()
}
}
function scheduleNext() {
clearTimer()
if (!disposed && autoRefresh.value && document.visibilityState === 'visible') {
timer = setTimeout(fetchInfo, refreshInterval.value)
}
}
function clearTimer() {
if (timer) {
clearInterval(timer)
clearTimeout(timer)
timer = null
}
}
function changeInterval(val: number) {
refreshInterval.value = val
startTimer()
function changeInterval() {
scheduleNext()
}
/** 温度颜色 */
function tempColor(t?: number) {
if (t == null) return ''
if (t >= 80) return '#f56c6c'
if (t >= 70) return '#e6a23c'
return '#303133'
function changeAutoRefresh() {
if (autoRefresh.value) scheduleNext()
else clearTimer()
}
onMounted(() => {
fetchInfo()
startTimer()
function handleVisibilityChange() {
if (document.visibilityState === 'hidden') clearTimer()
else if (autoRefresh.value) fetchInfo()
}
function openGpuDetail(index: number) {
selectedGpuIndex.value = index
drawerVisible.value = true
nextTick(() => document.querySelector<HTMLElement>('.gpu-detail-drawer .el-drawer__body')?.focus())
}
function handleGpuKeydown(event: KeyboardEvent, index: number) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openGpuDetail(index)
}
}
function formatNumber(value?: number, digits = 1) {
return Number(value || 0).toFixed(digits).replace(/\.0$/, '')
}
function formatRate(value?: number) {
return value == null ? '--' : formatNumber(value)
}
function formatUptime(seconds?: number) {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return `${days ? `${days}` : ''}${hours}小时 ${minutes}分钟`
}
function formatLastUpdated(date: Date | null) {
return date ? date.toLocaleTimeString('zh-CN', { hour12: false }) : '等待首次更新'
}
onMounted(fetchInfo)
onMounted(() => document.addEventListener('visibilitychange', handleVisibilityChange))
onUnmounted(() => {
disposed = true
clearTimer()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
onUnmounted(stopTimer)
</script>
<template>
<PageCard title="平台性能">
<PageCard title="平台性能" subtitle="实时监控计算、存储、网络与 GPU 资源池状态">
<template #extra>
<span style="font-size: 13px; color: #909399">刷新频率</span>
<el-select v-model="refreshInterval" size="small" style="width: 90px" @change="changeInterval">
<el-option :value="1000" label="1秒" />
<el-option :value="3000" label="3秒" />
<el-option :value="5000" label="5秒" />
<div class="monitor-actions">
<div class="health-state" :class="systemHealth.className">
<span class="health-dot" aria-hidden="true" />
{{ systemHealth.label }}
</div>
<span class="update-time">更新于 {{ formatLastUpdated(lastUpdated) }}</span>
<el-switch
v-model="autoRefresh"
inline-prompt
active-text="自动"
inactive-text="暂停"
aria-label="自动刷新"
@change="changeAutoRefresh"
/>
<el-select
v-model="refreshInterval"
class="interval-select"
size="small"
aria-label="刷新频率"
:disabled="!autoRefresh"
@change="changeInterval"
>
<el-option :value="1000" label="1 秒" />
<el-option :value="3000" label="3 秒" />
<el-option :value="5000" label="5 秒" />
<el-option :value="10000" label="10 秒" />
</el-select>
<el-button :loading="refreshing" aria-label="立即刷新平台性能数据" @click="fetchInfo">
<i class="fa fa-refresh" aria-hidden="true" />
刷新
</el-button>
</div>
</template>
<!-- CPU / 内存 / 磁盘 -->
<el-row :gutter="16" style="margin-bottom: 16px">
<!-- CPU -->
<el-col :span="8">
<el-card shadow="never">
<div class="metric-header">
<el-alert
v-if="loadError"
class="load-alert"
type="warning"
:closable="false"
show-icon
title="最新数据获取失败,当前仍展示上一次成功采集的数据"
:description="loadError"
/>
<div v-loading="loading" class="performance-content">
<section class="overview-grid" aria-label="硬件资源概览">
<article class="overview-card">
<div class="metric-heading">
<span class="metric-icon is-cpu"><i class="fa fa-microchip" /></span>
<div><h3>CPU</h3><p>{{ info.cpu?.model || `${info.cpu?.cores || 0} 个逻辑核心` }}</p></div>
</div>
<strong class="metric-number">{{ safePercent(info.cpu?.percent) }}<small>%</small></strong>
<el-progress :percentage="safePercent(info.cpu?.percent)" :stroke-width="6" :show-text="false" :color="progressColor(safePercent(info.cpu?.percent))" />
</article>
<article class="overview-card">
<div class="metric-heading">
<span class="metric-icon is-memory"><i class="fa fa-database" /></span>
<div><h3>内存</h3><p>{{ formatNumber(info.memory?.used_gb) }} / {{ formatNumber(info.memory?.total_gb) }} GB</p></div>
</div>
<strong class="metric-number">{{ safePercent(info.memory?.percent) }}<small>%</small></strong>
<el-progress :percentage="safePercent(info.memory?.percent)" :stroke-width="6" :show-text="false" :color="progressColor(safePercent(info.memory?.percent))" />
</article>
<article class="overview-card">
<div class="metric-heading">
<span class="metric-icon is-disk"><i class="fa fa-hdd-o" /></span>
<div><h3>磁盘</h3><p>{{ formatNumber(info.disk?.used_gb) }} / {{ formatNumber(info.disk?.total_gb) }} GB</p></div>
</div>
<strong class="metric-number">{{ safePercent(info.disk?.percent) }}<small>%</small></strong>
<el-progress :percentage="safePercent(info.disk?.percent)" :stroke-width="6" :show-text="false" :color="progressColor(safePercent(info.disk?.percent))" />
</article>
<article class="overview-card">
<div class="metric-heading">
<span class="metric-icon is-network"><i class="fa fa-exchange" /></span>
<div><h3>网络吞吐</h3><p>实时接收 / 发送</p></div>
</div>
<div class="network-values">
<strong><i class="fa fa-arrow-down" /> {{ formatRate(info.network?.download_mb_s) }} <small>MB/s</small></strong>
<strong><i class="fa fa-arrow-up" /> {{ formatRate(info.network?.upload_mb_s) }} <small>MB/s</small></strong>
</div>
</article>
</section>
<section class="panel resource-panel" aria-labelledby="resource-trend-title">
<div class="panel-heading">
<div><h2 id="resource-trend-title">系统资源趋势</h2><p>展示最近 60 次前端采集快照</p></div>
<span class="sample-count">{{ snapshots.length }} / 60 个采样点</span>
</div>
<VChart class="resource-chart" :option="resourceChartOption" autoresize aria-label="CPU内存磁盘和GPU平均利用率趋势图" />
</section>
<section class="gpu-section" aria-labelledby="gpu-pool-title">
<div class="section-heading">
<div>
<i class="fa fa-microchip metric-icon cpu-icon" />
<div class="metric-info">
<div class="metric-title">CPU 使用率</div>
<div class="metric-sub">{{ info.cpu?.cores || 0 }} 核心</div>
<h2 id="gpu-pool-title">GPU 资源池</h2>
<p> {{ gpus.length }} 张卡{{ busyGpuCount }} 张运行中 · 显存 {{ formatNumber(gpuMemoryUsed) }} / {{ formatNumber(gpuMemoryTotal) }} GB</p>
</div>
<div class="gpu-summary"><span>平均利用率</span><strong>{{ gpuAverage }}%</strong></div>
</div>
<div class="metric-value">{{ info.cpu?.percent || 0 }}%</div>
</div>
<el-progress :percentage="info.cpu?.percent || 0" :stroke-width="10" :show-text="false" color="#1890ff" />
</el-card>
</el-col>
<!-- 内存 -->
<el-col :span="8">
<el-card shadow="never">
<div class="metric-header">
<div>
<i class="fa fa-database metric-icon mem-icon" />
<div class="metric-info">
<div class="metric-title">内存使用</div>
<div class="metric-sub">总计: {{ info.memory?.total_gb || 0 }} GB</div>
<el-empty v-if="!gpus.length && !loading" description="当前未检测到 GPU 设备" />
<div v-else class="gpu-grid">
<article
v-for="(gpu, index) in gpus"
:key="gpu.uuid || gpu.id || index"
class="gpu-card"
role="button"
tabindex="0"
:aria-label="`查看 GPU ${index} 详情`"
@click="openGpuDetail(index)"
@keydown="handleGpuKeydown($event, index)"
>
<div class="gpu-card-heading">
<div class="gpu-identity"><span class="gpu-index">GPU {{ index }}</span><h3>{{ gpu.name }}</h3></div>
<span class="status-pill" :class="gpuStatus(gpu).className"><i />{{ gpuStatus(gpu).label }}</span>
</div>
<div class="gpu-primary">
<strong>{{ safePercent(gpu.gpu_percent) }}<small>%</small></strong>
<span>计算利用率</span>
</div>
<div class="metric-value">{{ info.memory?.percent || 0 }}%</div>
<el-progress :percentage="safePercent(gpu.gpu_percent)" :stroke-width="7" :show-text="false" :color="progressColor(safePercent(gpu.gpu_percent))" />
<dl class="gpu-metrics">
<div><dt>显存</dt><dd>{{ formatNumber(gpu.memory_used_gb) }} / {{ formatNumber(gpu.memory_total_gb) }} GB</dd></div>
<div><dt>温度</dt><dd :class="{ 'is-hot': gpu.temperature >= 80, 'is-warm': gpu.temperature >= 70 && gpu.temperature < 80 }">{{ gpu.temperature }}°C</dd></div>
<div><dt>功耗</dt><dd>{{ formatNumber(gpu.power_w, 0) }} W</dd></div>
<div><dt>风扇</dt><dd>{{ gpu.fan_speed ?? '-' }}{{ gpu.fan_speed != null ? '%' : '' }}</dd></div>
</dl>
<div class="gpu-card-footer">查看设备详情 <i class="fa fa-angle-right" /></div>
</article>
</div>
<el-progress :percentage="info.memory?.percent || 0" :stroke-width="10" :show-text="false" color="#e6a23c" />
<div class="metric-detail">
<span>已用 {{ info.memory?.used_gb || 0 }} GB</span>
<span>可用 {{ info.memory?.available_gb || 0 }} GB</span>
<span>缓存 {{ info.memory?.cached_gb || 0 }} GB</span>
</div>
</el-card>
</el-col>
</section>
<!-- 磁盘 -->
<el-col :span="8">
<el-card shadow="never">
<div class="metric-header">
<div>
<i class="fa fa-hdd-o metric-icon disk-icon" />
<div class="metric-info">
<div class="metric-title">磁盘使用</div>
<div class="metric-sub">总计: {{ info.disk?.total_gb || 0 }} GB</div>
<section class="panel host-panel" aria-labelledby="host-info-title">
<div class="panel-heading compact"><div><h2 id="host-info-title">主机信息</h2><p>节点运行环境与基础状态</p></div></div>
<dl class="host-info-grid">
<div><dt>操作系统</dt><dd>{{ info.system?.os || '-' }}</dd></div>
<div><dt>运行时长</dt><dd>{{ formatUptime(info.system?.uptime_seconds) }}</dd></div>
<div><dt>运行进程</dt><dd>{{ info.system?.process_count ?? '-' }}</dd></div>
<div><dt>GPU 驱动</dt><dd>{{ gpus[0]?.driver_version || '-' }}</dd></div>
</dl>
</section>
</div>
</div>
<div class="metric-value">{{ info.disk?.percent || 0 }}%</div>
</div>
<el-progress :percentage="info.disk?.percent || 0" :stroke-width="10" :show-text="false" color="#67c23a" />
<div class="metric-detail">
<span>已用 {{ info.disk?.used_gb || 0 }} GB</span>
<span>可用 {{ (info.disk?.total_gb || 0) - (info.disk?.used_gb || 0) }} GB</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- GPU -->
<el-card shadow="never" style="margin-bottom: 16px">
<template #header>
<div class="section-header">
<i class="fa fa-microchip" style="color: #f56c6c" />
<span>GPU 监控{{ info.gpu?.length || 0 }} </span>
</div>
</template>
<el-row :gutter="12">
<el-col v-for="(gpu, idx) in info.gpu" :key="idx" :span="6">
<el-card shadow="never" class="gpu-card">
<div class="gpu-name">{{ gpu.name }}</div>
<div class="gpu-usage">{{ gpu.gpu_percent }}%</div>
<el-progress :percentage="gpu.gpu_percent" :stroke-width="8" :show-text="false" />
<div class="gpu-meta">
<div>显存: {{ gpu.memory_used_gb }}/{{ gpu.memory_total_gb }} GB</div>
<div>温度: <span :style="{ color: tempColor(gpu.temperature) }">{{ gpu.temperature }}°C</span></div>
<div>功耗: {{ gpu.power_w }} W</div>
<div>风扇: {{ gpu.fan_speed || 0 }}%</div>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
<!-- 系统信息 -->
<el-row :gutter="16">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div class="section-header">
<i class="fa fa-globe" style="color: #909399" />
<span>网络流量</span>
</div>
</template>
<div class="metric-detail">
<span>总流入: {{ info.network?.download_mb || 0 }} GB</span>
<span>总流出: {{ info.network?.upload_mb || 0 }} GB</span>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div class="section-header">
<i class="fa fa-info-circle" style="color: #909399" />
<span>系统信息</span>
</div>
</template>
<div class="sys-info">
<div class="sys-row"><span>操作系统</span><span>{{ info.system?.os || '-' }}</span></div>
<div class="sys-row"><span>进程数</span><span>{{ info.system?.process_count || 0 }}</span></div>
</div>
</el-card>
</el-col>
</el-row>
</PageCard>
<el-drawer v-model="drawerVisible" class="gpu-detail-drawer" size="min(720px, 92vw)" destroy-on-close>
<template #header>
<div v-if="selectedGpu" class="drawer-heading">
<span class="gpu-index">GPU {{ selectedGpuIndex }}</span>
<div><h2>{{ selectedGpu.name }}</h2><p>{{ selectedGpu.uuid || '设备详细运行信息' }}</p></div>
<span class="status-pill" :class="gpuStatus(selectedGpu).className"><i />{{ gpuStatus(selectedGpu).label }}</span>
</div>
</template>
<template v-if="selectedGpu">
<section class="drawer-metrics" aria-label="GPU 关键指标">
<div><span>计算利用率</span><strong>{{ safePercent(selectedGpu.gpu_percent) }}%</strong></div>
<div><span>显存占用</span><strong>{{ memoryPercent(selectedGpu) }}%</strong><small>{{ formatNumber(selectedGpu.memory_used_gb) }} / {{ formatNumber(selectedGpu.memory_total_gb) }} GB</small></div>
<div><span>温度</span><strong>{{ selectedGpu.temperature }}°C</strong></div>
<div><span>实时功耗</span><strong>{{ formatNumber(selectedGpu.power_w, 0) }} W</strong><small v-if="selectedGpu.power_limit_w">上限 {{ selectedGpu.power_limit_w }} W</small></div>
</section>
<section class="drawer-section" aria-labelledby="gpu-trend-title">
<div class="drawer-section-heading"><h3 id="gpu-trend-title">设备趋势</h3><span>最近 60 次采样</span></div>
<VChart class="gpu-detail-chart" :option="gpuChartOption" autoresize aria-label="单卡利用率和显存占用趋势图" />
</section>
<section class="drawer-section" aria-labelledby="gpu-process-title">
<div class="drawer-section-heading"><h3 id="gpu-process-title">GPU 进程</h3><span>{{ selectedGpu.processes?.length || 0 }} </span></div>
<el-table :data="selectedGpu.processes || []" stripe empty-text="当前没有占用此 GPU 的进程">
<el-table-column prop="pid" label="PID" width="90" />
<el-table-column prop="task_name" label="任务" min-width="130" show-overflow-tooltip />
<el-table-column prop="name" label="进程" min-width="130" show-overflow-tooltip />
<el-table-column prop="user" label="用户" width="100" />
<el-table-column label="显存" width="110" align="right">
<template #default="{ row }">{{ row.memory_used_gb != null ? `${formatNumber(row.memory_used_gb)} GB` : '-' }}</template>
</el-table-column>
</el-table>
</section>
<section class="drawer-section device-properties" aria-labelledby="gpu-property-title">
<div class="drawer-section-heading"><h3 id="gpu-property-title">设备属性</h3></div>
<dl>
<div><dt>风扇转速</dt><dd>{{ selectedGpu.fan_speed != null ? `${selectedGpu.fan_speed}%` : '-' }}</dd></div>
<div><dt>核心频率</dt><dd>{{ selectedGpu.clock_mhz ? `${selectedGpu.clock_mhz} MHz` : '-' }}</dd></div>
<div><dt>驱动版本</dt><dd>{{ selectedGpu.driver_version || '-' }}</dd></div>
<div><dt>设备标识</dt><dd>{{ selectedGpu.uuid || selectedGpu.id || `GPU-${selectedGpuIndex}` }}</dd></div>
</dl>
</section>
</template>
</el-drawer>
</template>
<style scoped lang="scss">
.metric-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.monitor-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.health-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500; color: #606266; }
.health-dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, .12); }
.health-state.is-warning .health-dot { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, .12); }
.health-state.is-danger .health-dot { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, .12); }
.update-time { color: #909399; font-size: 12px; }
.interval-select { width: 78px; }
.load-alert { margin-bottom: 16px; }
.performance-content { min-height: 320px; }
.overview-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.overview-card, .panel, .gpu-card { border: 1px solid #e4e7ed; border-radius: 8px; background: #fff; }
.overview-card { position: relative; padding: 18px; overflow: hidden; }
.metric-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.metric-heading h3, .metric-heading p { margin: 0; }
.metric-heading h3 { color: #303133; font-size: 14px; font-weight: 600; }
.metric-heading p { margin-top: 2px; color: #909399; font-size: 12px; }
.metric-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 7px; font-size: 15px; }
.metric-icon.is-cpu { color: #4f46e5; background: #eef2ff; }
.metric-icon.is-memory { color: #0ea5e9; background: #ecfeff; }
.metric-icon.is-disk { color: #10b981; background: #ecfdf5; }
.metric-icon.is-network { color: #f59e0b; background: #fffbeb; }
.metric-number { display: block; margin-bottom: 10px; color: #1f2937; font-size: 29px; line-height: 1; }
.metric-number small, .network-values small, .gpu-primary small { margin-left: 2px; color: #909399; font-size: 13px; font-weight: 500; }
.network-values { display: flex; flex-wrap: wrap; gap: 12px 18px; min-height: 38px; align-items: center; }
.network-values strong { color: #374151; font-size: 17px; }
.network-values i { margin-right: 3px; color: #10b981; font-size: 12px; }
.network-values strong:last-child i { color: #f59e0b; }
.panel { padding: 18px 20px; }
.resource-panel { margin-bottom: 24px; }
.panel-heading, .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.panel-heading h2, .panel-heading p, .section-heading h2, .section-heading p { margin: 0; }
.panel-heading h2, .section-heading h2 { color: #303133; font-size: 15px; font-weight: 600; }
.panel-heading p, .section-heading p { margin-top: 4px; color: #909399; font-size: 12px; }
.sample-count { color: #909399; font-size: 12px; }
.resource-chart { height: 260px; margin-top: 8px; }
.gpu-section { margin-bottom: 24px; }
.gpu-summary { min-width: 100px; text-align: right; }
.gpu-summary span { display: block; color: #909399; font-size: 12px; }
.gpu-summary strong { color: #303133; font-size: 22px; }
.gpu-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }
.gpu-card { padding: 16px; cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
.gpu-card:hover, .gpu-card:focus-visible { border-color: #a5b4fc; box-shadow: 0 5px 16px rgba(79, 70, 229, .08); outline: none; transform: translateY(-1px); }
.gpu-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.gpu-identity { min-width: 0; }
.gpu-index { display: inline-block; margin-bottom: 4px; padding: 2px 7px; border-radius: 4px; color: #4338ca; background: #eef2ff; font-size: 11px; font-weight: 600; }
.gpu-identity h3 { overflow: hidden; margin: 0; color: #303133; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { display: inline-flex; flex: none; align-items: center; gap: 5px; padding: 3px 7px; border-radius: 999px; color: #047857; background: #ecfdf5; font-size: 11px; }
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-pill.is-busy { color: #4338ca; background: #eef2ff; }
.status-pill.is-warning { color: #b45309; background: #fffbeb; }
.status-pill.is-danger { color: #b91c1c; background: #fef2f2; }
.gpu-primary { display: flex; align-items: baseline; justify-content: space-between; margin: 17px 0 8px; }
.gpu-primary strong { color: #1f2937; font-size: 25px; }
.gpu-primary span { color: #909399; font-size: 11px; }
.gpu-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; margin: 15px 0 0; }
.gpu-metrics div { min-width: 0; }
.gpu-metrics dt { margin-bottom: 2px; color: #909399; font-size: 11px; }
.gpu-metrics dd { overflow: hidden; margin: 0; color: #606266; font-size: 12px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.gpu-metrics dd.is-warm { color: #b45309; }
.gpu-metrics dd.is-hot { color: #dc2626; }
.gpu-card-footer { margin: 14px -16px -16px; padding: 9px 16px; border-top: 1px solid #f0f2f5; color: #909399; font-size: 11px; text-align: right; }
.host-panel { padding-bottom: 4px; }
.host-info-grid { display: grid; grid-template-columns: 2fr 1.2fr 1fr 1fr; margin: 15px 0 0; }
.host-info-grid > div { min-width: 0; padding: 0 18px 14px; border-left: 1px solid #ebeef5; }
.host-info-grid > div:first-child { padding-left: 0; border-left: 0; }
.host-info-grid dt { margin-bottom: 5px; color: #909399; font-size: 12px; }
.host-info-grid dd { overflow: hidden; margin: 0; color: #303133; font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.drawer-heading { display: flex; width: 100%; align-items: center; gap: 12px; }
.drawer-heading .gpu-index { margin: 0; }
.drawer-heading div { min-width: 0; flex: 1; }
.drawer-heading h2, .drawer-heading p { margin: 0; }
.drawer-heading h2 { color: #303133; font-size: 16px; }
.drawer-heading p { overflow: hidden; margin-top: 3px; color: #909399; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.drawer-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.drawer-metrics > div { min-width: 0; padding: 13px; border: 1px solid #e4e7ed; border-radius: 7px; background: #fafafa; }
.drawer-metrics span, .drawer-metrics small { display: block; overflow: hidden; color: #909399; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.drawer-metrics strong { display: block; margin: 6px 0 2px; color: #303133; font-size: 20px; }
.drawer-section { margin-top: 22px; }
.drawer-section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.drawer-section-heading h3 { margin: 0; color: #303133; font-size: 14px; }
.drawer-section-heading span { color: #909399; font-size: 11px; }
.gpu-detail-chart { height: 230px; border: 1px solid #ebeef5; border-radius: 7px; }
.device-properties dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; border: 1px solid #ebeef5; border-radius: 7px; }
.device-properties dl > div { display: flex; min-width: 0; justify-content: space-between; gap: 14px; padding: 11px 13px; border-bottom: 1px solid #ebeef5; }
.device-properties dl > div:nth-last-child(-n + 2) { border-bottom: 0; }
.device-properties dt { color: #909399; font-size: 12px; }
.device-properties dd { overflow: hidden; margin: 0; color: #303133; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
> div:first-child {
display: flex;
align-items: center;
gap: 10px;
}
:deep(.gpu-detail-drawer .el-drawer__header) { margin-bottom: 0; padding-bottom: 17px; border-bottom: 1px solid #ebeef5; }
:deep(.gpu-detail-drawer .el-drawer__body) { outline: none; }
@media (max-width: 1280px) {
.overview-grid, .gpu-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.metric-icon {
font-size: 20px;
}
.cpu-icon {
color: #1890ff;
}
.mem-icon {
color: #e6a23c;
}
.disk-icon {
color: #67c23a;
@media (max-width: 860px) {
.monitor-actions { justify-content: flex-start; }
.update-time { width: 100%; }
.host-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.host-info-grid > div:nth-child(3) { padding-left: 0; border-left: 0; }
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.metric-title {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.metric-sub {
font-size: 12px;
color: #909399;
}
.metric-value {
font-size: 24px;
font-weight: 500;
color: #303133;
}
.metric-detail {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 13px;
color: #606266;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #303133;
}
.gpu-card {
.gpu-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gpu-usage {
font-size: 18px;
font-weight: 600;
color: #f56c6c;
margin-bottom: 8px;
}
.gpu-meta {
margin-top: 8px;
font-size: 11px;
color: #909399;
line-height: 1.8;
}
}
.sys-info {
.sys-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ebeef5;
font-size: 13px;
&:last-child {
border-bottom: none;
}
span:first-child {
color: #909399;
}
}
@media (max-width: 620px) {
.overview-grid, .gpu-grid { grid-template-columns: 1fr; }
.resource-chart { height: 230px; }
.section-heading { flex-direction: column; }
.gpu-summary { text-align: left; }
.host-info-grid, .device-properties dl { grid-template-columns: 1fr; }
.host-info-grid > div { padding-left: 0; border-left: 0; }
.device-properties dl > div { border-bottom: 1px solid #ebeef5 !important; }
.device-properties dl > div:last-child { border-bottom: 0 !important; }
}
</style>