Files
YG_FT/frontend/src/views/system/HardwareView.vue

594 lines
28 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import VChart from 'vue-echarts'
import PageCard from '@/components/PageCard.vue'
import '@/plugins/echarts-hardware'
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>