Files
YG_FT/frontend/src/views/system/HardwareView.vue
caoxiaozhu 6b48b8fac3 feat: 硬件看板展示 GPU 进程
HardwareView 增加显卡进程占用列表展示,配套回归脚本与 npm test:hardware 脚本注册。
2026-07-13 10:31:29 +08:00

592 lines
27 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 setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import PageCard from '@/components/PageCard.vue'
import { getSystemInfo } from '@/api/modules/system'
import type { GpuInfo, SystemInfo } from '@/types'
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)
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 safePercent(value?: number) {
return Math.min(100, Math.max(0, Number(value || 0)))
}
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) {
clearTimeout(timer)
timer = null
}
}
function changeInterval() {
scheduleNext()
}
function changeAutoRefresh() {
if (autoRefresh.value) scheduleNext()
else clearTimer()
}
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)
})
</script>
<template>
<PageCard title="平台性能" subtitle="实时监控计算、存储、网络与 GPU 资源池状态">
<template #extra>
<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>
<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>
<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>
<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>
<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>
</section>
<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>
</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">
.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; }
: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)); }
}
@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)); }
}
@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>