独立监控核心和模块
This commit is contained in:
@@ -1,24 +1,593 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity } from '@lucide/vue'
|
||||
import {
|
||||
Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick,
|
||||
Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle,
|
||||
ShieldCheck, ShieldAlert, Zap, Thermometer, Clock, ChevronDown,
|
||||
} from '@lucide/vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const store = useMonitorStore()
|
||||
|
||||
// ===== Tab 配置(注册到 TitleBar 浮动切换器) =====
|
||||
const activeTab = ref('overview')
|
||||
const tabsListRef = useModuleTabs(activeTab, [
|
||||
{ value: 'overview', label: '概览' },
|
||||
{ value: 'details', label: '详细' },
|
||||
{ value: 'settings', label: '设置' },
|
||||
])
|
||||
|
||||
// ===== 关键指标历史 buffer(用于概览页 sparkline,不引入外部图表库) =====
|
||||
// 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次)
|
||||
const HISTORY_LEN = 30
|
||||
const cpuTempHistory = ref<number[]>([])
|
||||
const cpuLoadHistory = ref<number[]>([])
|
||||
const memLoadHistory = ref<number[]>([])
|
||||
const gpuLoadHistory = ref<number[]>([])
|
||||
|
||||
function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
|
||||
if (v == null || !isFinite(v)) return
|
||||
buf.value.push(v)
|
||||
if (buf.value.length > HISTORY_LEN) buf.value.shift()
|
||||
}
|
||||
|
||||
// 监听快照变化,提取关键指标入历史
|
||||
watch(() => store.snapshot, (snap) => {
|
||||
if (!snap) return
|
||||
pushHistory(cpuTempHistory, store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
|
||||
pushHistory(cpuLoadHistory, store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
|
||||
// Memory 分组有两条同名 "Memory" 负载传感器(Virtual Memory + Total Memory),优先取 Total Memory
|
||||
pushHistory(memLoadHistory, store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' }))
|
||||
// Intel GPU 3D 负载传感器名为 "D3D 3D";部分 GPU 为 "GPU Core"
|
||||
pushHistory(gpuLoadHistory,
|
||||
store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ??
|
||||
store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ??
|
||||
store.findSensorValue('gpuintel', { type: 'load' }),
|
||||
)
|
||||
}, { deep: false })
|
||||
|
||||
// ===== 关键指标 computed =====
|
||||
const cpuTemp = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
|
||||
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
|
||||
const memLoad = computed(() => store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' }))
|
||||
const gpuLoad = computed(() =>
|
||||
store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ??
|
||||
store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ??
|
||||
store.findSensorValue('gpuintel', { type: 'load' }),
|
||||
)
|
||||
const storageTemp = computed(() => store.findSensorValue('storage', { type: 'temperature' }))
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
|
||||
function fmt(v: number | null, digits = 1): string {
|
||||
if (v == null || !isFinite(v)) return '--'
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
|
||||
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
|
||||
function tempColor(t: number | null): string {
|
||||
if (t == null) return 'text-muted-foreground'
|
||||
if (t < 50) return 'text-emerald-500'
|
||||
if (t < 70) return 'text-yellow-500'
|
||||
if (t < 85) return 'text-orange-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
|
||||
function loadColor(v: number | null): string {
|
||||
if (v == null) return 'text-muted-foreground'
|
||||
if (v < 50) return 'text-sky-500'
|
||||
if (v < 80) return 'text-violet-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
/** 传感器类型 → 中文标签 */
|
||||
const typeLabels: Record<string, string> = {
|
||||
temperature: '温度',
|
||||
load: '负载',
|
||||
power: '功率',
|
||||
voltage: '电压',
|
||||
fan: '风扇',
|
||||
clock: '时钟',
|
||||
data: '容量',
|
||||
smalldata: '容量',
|
||||
throughput: '吞吐',
|
||||
level: '等级',
|
||||
control: '控制',
|
||||
frequency: '频率',
|
||||
factor: '因子',
|
||||
timespan: '时长',
|
||||
energy: '能量',
|
||||
noise: '噪声',
|
||||
conductivity: '电导率',
|
||||
humidity: '湿度',
|
||||
flow: '流量',
|
||||
}
|
||||
|
||||
function typeLabel(t: string): string {
|
||||
return typeLabels[t] ?? t
|
||||
}
|
||||
|
||||
/** 分组 id → 显示名 + 图标组件 */
|
||||
const groupMeta: Record<string, { name: string; icon: typeof Cpu }> = {
|
||||
cpu: { name: 'CPU', icon: Cpu },
|
||||
memory: { name: '内存', icon: MemoryStick },
|
||||
gpuintel: { name: 'GPU', icon: Gauge },
|
||||
gpuamd: { name: 'GPU', icon: Gauge },
|
||||
gpunvidia: { name: 'GPU', icon: Gauge },
|
||||
storage: { name: '存储', icon: HardDrive },
|
||||
motherboard: { name: '主板', icon: Activity },
|
||||
superio: { name: '超级 IO', icon: Activity },
|
||||
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
|
||||
battery: { name: '电池', icon: Activity },
|
||||
network: { name: '网络', icon: Activity },
|
||||
psu: { name: '电源', icon: Zap },
|
||||
}
|
||||
|
||||
function groupDisplayName(id: string, fallback: string): string {
|
||||
return groupMeta[id]?.name ?? fallback
|
||||
}
|
||||
|
||||
function groupIcon(id: string): typeof Cpu {
|
||||
return groupMeta[id]?.icon ?? Activity
|
||||
}
|
||||
|
||||
// ===== 连接状态徽章 =====
|
||||
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
|
||||
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
|
||||
loading: { text: '启动中', class: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' },
|
||||
connected: { text: '已连接', class: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' },
|
||||
disconnected: { text: '已断开', class: 'bg-orange-500/15 text-orange-600 dark:text-orange-400' },
|
||||
error: { text: '错误', class: 'bg-red-500/15 text-red-600 dark:text-red-400' },
|
||||
}
|
||||
|
||||
// ===== sparkline 路径生成(无外部依赖) =====
|
||||
function sparklinePath(data: number[], opts: { min?: number; max?: number; w?: number; h?: number } = {}): string {
|
||||
if (data.length < 2) return ''
|
||||
const w = opts.w ?? 100
|
||||
const h = opts.h ?? 28
|
||||
const min = opts.min ?? Math.min(...data)
|
||||
const max = opts.max ?? Math.max(...data)
|
||||
const range = max - min || 1
|
||||
const step = w / (data.length - 1)
|
||||
return data.map((v, i) => {
|
||||
const x = i * step
|
||||
const y = h - ((v - min) / range) * h
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
// ===== 分组列表(详细页用) =====
|
||||
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
|
||||
|
||||
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用) */
|
||||
function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[] {
|
||||
const byHw = new Map<string, SensorEntry[]>()
|
||||
for (const s of sensors) {
|
||||
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
|
||||
byHw.get(s.hardwareName)!.push(s)
|
||||
}
|
||||
return Array.from(byHw.entries()).map(([hw, items]) => {
|
||||
const byType = new Map<string, SensorEntry[]>()
|
||||
for (const s of items) {
|
||||
if (!byType.has(s.type)) byType.set(s.type, [])
|
||||
byType.get(s.type)!.push(s)
|
||||
}
|
||||
return {
|
||||
hardware: hw,
|
||||
byType: Array.from(byType.entries()).map(([type, list]) => ({ type, items: list })),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Accordion 折叠状态 =====
|
||||
const accordionValue = ref<string[]>([])
|
||||
|
||||
// ===== 控制操作 =====
|
||||
async function handleStart() {
|
||||
await store.start()
|
||||
if (store.connState === 'connected') toast.success('Kernel 已启动')
|
||||
else if (store.errorMsg) toast.error('启动失败', { description: store.errorMsg })
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
await store.stop()
|
||||
toast.success('Kernel 已停止')
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await store.refreshStatus()
|
||||
if (store.status?.ready) {
|
||||
await store.fetchSnapshot()
|
||||
toast.success('已刷新')
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
store.init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispose()
|
||||
})
|
||||
|
||||
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
|
||||
watch(() => store.status?.ready, (ready, prev) => {
|
||||
if (ready && !prev) {
|
||||
store.fetchSnapshot()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full p-6 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Activity class="h-5 w-5 text-primary" />
|
||||
硬件监控模块
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Activity class="h-16 w-16 mb-4 opacity-50" />
|
||||
<p>硬件监控功能开发中...</p>
|
||||
<p class="text-sm mt-2">支持 CPU、GPU、内存、硬盘等硬件数据监控</p>
|
||||
<div class="h-full p-6">
|
||||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
||||
<div ref="tabsListRef">
|
||||
<TabsList class="grid w-full max-w-md grid-cols-3 !bg-transparent !p-0 !shadow-none">
|
||||
<TabsTrigger value="overview" class="gap-1.5"><Activity class="size-3.5" />概览</TabsTrigger>
|
||||
<TabsTrigger value="details" class="gap-1.5"><Gauge class="size-3.5" />详细</TabsTrigger>
|
||||
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<!-- ===== 概览 ===== -->
|
||||
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
||||
<ScrollArea class="h-full pr-3">
|
||||
<!-- 未启动占位 -->
|
||||
<div v-if="!store.initialized" key="initializing" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
|
||||
<Loader2 class="size-8 animate-spin" />
|
||||
<p class="text-sm">正在加载...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.connState === 'idle'" key="idle" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-3 pt-10">
|
||||
<Activity class="size-12 opacity-30" />
|
||||
<p class="text-sm">Kernel 未启动</p>
|
||||
<Button size="sm" :disabled="store.starting" @click="handleStart">
|
||||
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
|
||||
<Play v-else class="size-3.5" />启动 Kernel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
|
||||
<!-- 状态卡片 -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center justify-between text-base">
|
||||
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />Kernel 状态</span>
|
||||
<span :class="['text-xs px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
|
||||
{{ stateMeta[store.connState].text }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">进程</span>
|
||||
<span>{{ store.status?.running ? '运行中' : '已停止' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">PID</span>
|
||||
<span class="font-mono">{{ store.status?.pid ?? '--' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">就绪</span>
|
||||
<span>{{ store.status?.ready ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">传感器数</span>
|
||||
<span>{{ store.status?.sensorCount ?? '--' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">重启次数</span>
|
||||
<span>{{ store.status?.restartCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">事件数</span>
|
||||
<span>{{ store.eventCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<component :is="store.snapshot?.isAdmin ? ShieldCheck : ShieldAlert" class="size-3.5" />
|
||||
权限
|
||||
</span>
|
||||
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
|
||||
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="!store.snapshot?.isAdmin" class="text-xs text-orange-600 dark:text-orange-400 flex items-start gap-1.5 pt-1">
|
||||
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
|
||||
<span>非管理员运行,部分传感器(电压/主板/SMBus)可能不可读。可在设置页查看降级说明。</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping" @click="handleRefresh">
|
||||
<RefreshCw class="size-3" />刷新
|
||||
</Button>
|
||||
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping" @click="handleStop">
|
||||
<Square class="size-3" />停止
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- CPU 关键指标 -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Cpu class="size-4 text-primary" />CPU
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />封装温度</div>
|
||||
<div :class="['text-2xl font-bold tabular-nums', tempColor(cpuTemp)]">
|
||||
{{ fmt(cpuTemp, 0) }}<span class="text-sm font-normal">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg v-if="cpuTempHistory.length >= 2" :width="100" :height="28" class="opacity-80">
|
||||
<path :d="sparklinePath(cpuTempHistory)" :stroke="'currentColor'" :class="tempColor(cpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />总负载</span>
|
||||
<span :class="['font-medium tabular-nums', loadColor(cpuLoad)]">{{ fmt(cpuLoad, 0) }}%</span>
|
||||
</div>
|
||||
<Progress :model-value="cpuLoad ?? 0" class="h-1.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 内存 -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<MemoryStick class="size-4 text-primary" />内存
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground">内存负载</span>
|
||||
<span :class="['font-medium tabular-nums', loadColor(memLoad)]">{{ fmt(memLoad, 0) }}%</span>
|
||||
</div>
|
||||
<Progress :model-value="memLoad ?? 0" class="h-1.5" />
|
||||
</div>
|
||||
<svg v-if="memLoadHistory.length >= 2" :width="'100%'" :height="32" class="opacity-80" preserveAspectRatio="none" viewBox="0 0 100 32">
|
||||
<path :d="sparklinePath(memLoadHistory, { min: 0, max: 100, w: 100, h: 32 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- GPU -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Gauge class="size-4 text-primary" />GPU
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div v-if="gpuLoad != null">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground">3D 负载</span>
|
||||
<span :class="['font-medium tabular-nums', loadColor(gpuLoad)]">{{ fmt(gpuLoad, 0) }}%</span>
|
||||
</div>
|
||||
<Progress :model-value="gpuLoad ?? 0" class="h-1.5" />
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted-foreground py-2 text-center">暂无 GPU 数据</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 存储 -->
|
||||
<Card v-if="storageTemp != null">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<HardDrive class="size-4 text-primary" />存储
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />温度</div>
|
||||
<div :class="['text-2xl font-bold tabular-nums', tempColor(storageTemp)]">
|
||||
{{ fmt(storageTemp, 0) }}<span class="text-sm font-normal">°C</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 断线提示 -->
|
||||
<Card v-if="store.connState === 'disconnected'" class="border-orange-500/40">
|
||||
<CardContent class="pt-4 flex items-start gap-2 text-sm">
|
||||
<AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div class="font-medium text-orange-600 dark:text-orange-400">SSE 连接断开</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">Kernel 可能正在重启,等待自动重连...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<Card v-if="store.errorMsg" class="border-red-500/40">
|
||||
<CardContent class="pt-4 flex items-start gap-2 text-sm">
|
||||
<AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div class="font-medium text-red-600 dark:text-red-400">错误</div>
|
||||
<div class="text-xs font-mono mt-0.5 break-all">{{ store.errorMsg }}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<!-- ===== 详细 ===== -->
|
||||
<TabsContent value="details" class="flex-1 mt-4 tab-animate">
|
||||
<div v-if="!store.snapshot" key="no-snapshot" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
|
||||
<Gauge class="size-12 opacity-30" />
|
||||
<p class="text-sm">无快照数据</p>
|
||||
<p v-if="store.connState === 'idle'" class="text-xs">请先在概览页启动 Kernel</p>
|
||||
<Button v-else size="xs" variant="outline" :disabled="!store.status?.ready" @click="store.fetchSnapshot()">
|
||||
<RefreshCw class="size-3" />拉取快照
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ScrollArea v-else key="details-list" class="h-full pr-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-muted-foreground">{{ groups.length }} 个分组 / {{ store.status?.sensorCount ?? 0 }} 个传感器</p>
|
||||
<Button size="xs" variant="outline" @click="store.fetchSnapshot()">
|
||||
<RefreshCw class="size-3" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Accordion v-model="accordionValue" type="multiple" class="w-full space-y-2 pb-4">
|
||||
<Card v-for="g in groups" :key="g.id" class="overflow-hidden py-0">
|
||||
<AccordionItem :value="g.id" class="border-b-0">
|
||||
<AccordionTrigger class="px-4 py-2.5 hover:no-underline">
|
||||
<div class="flex items-center justify-between w-full pr-2">
|
||||
<span class="flex items-center gap-2">
|
||||
<component :is="groupIcon(g.id)" class="size-4 text-primary" />
|
||||
<span class="font-medium">{{ groupDisplayName(g.id, g.name) }}</span>
|
||||
<Badge variant="outline" class="font-normal">{{ g.sensors.length }}</Badge>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">{{ g.id }}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="px-4 pb-3 pt-0">
|
||||
<div v-for="hwGroup in groupSensors(g.sensors)" :key="hwGroup.hardware" class="py-2 border-t first:border-t-0">
|
||||
<div class="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1">
|
||||
<ChevronDown class="size-3" />{{ hwGroup.hardware }}
|
||||
</div>
|
||||
<div v-for="typeGroup in hwGroup.byType" :key="typeGroup.type" class="mb-2">
|
||||
<div class="text-[11px] uppercase tracking-wide text-muted-foreground/70 mb-1">{{ typeLabel(typeGroup.type) }}</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-0.5 text-xs">
|
||||
<div v-for="s in typeGroup.items" :key="s.id" class="flex justify-between items-center py-0.5">
|
||||
<span class="text-muted-foreground truncate pr-2" :title="s.name">{{ s.name }}</span>
|
||||
<span class="font-mono tabular-nums shrink-0" :class="{ 'text-muted-foreground/50': s.value == null }">
|
||||
{{ s.value == null ? 'N/A' : s.value.toFixed(s.type === 'voltage' || s.type === 'power' ? 2 : 1) }}
|
||||
<span class="text-muted-foreground ml-0.5">{{ s.unit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Card>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<!-- ===== 设置 ===== -->
|
||||
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
|
||||
<ScrollArea class="h-full pr-3">
|
||||
<div class="max-w-2xl space-y-4 pb-4">
|
||||
<!-- 控制 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<Activity class="size-4 text-primary" />Kernel 控制
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 text-sm">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Button size="sm" :disabled="store.starting || store.stopping || store.connState !== 'idle'" @click="handleStart">
|
||||
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
|
||||
<Play v-else class="size-3.5" />启动
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
|
||||
<Loader2 v-if="store.stopping" class="size-3.5 animate-spin" />
|
||||
<Square v-else class="size-3.5" />停止
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="handleRefresh">
|
||||
<RefreshCw class="size-3.5" />刷新状态
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Kernel 由 ProcessManager 统一管理,应用启动时自动拉起,崩溃自动重启。
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Kernel 信息 -->
|
||||
<Card v-if="store.kernelInfo">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<SettingsIcon class="size-4 text-primary" />Kernel 信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">端口</span>
|
||||
<span class="font-mono">{{ store.kernelInfo.port }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">已安装</span>
|
||||
<Badge :variant="store.kernelInfo.exists ? 'default' : 'destructive'" :class="store.kernelInfo.exists ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
|
||||
{{ store.kernelInfo.exists ? '是' : '否' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<span class="text-muted-foreground shrink-0">路径</span>
|
||||
<span class="font-mono text-xs text-right break-all" :title="store.kernelInfo.path">{{ store.kernelInfo.path }}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 权限与降级说明 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<ShieldCheck class="size-4 text-primary" />权限与降级
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
||||
<p>当前权限:<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500 ml-1' : 'ml-1'">{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}</Badge></p>
|
||||
<Separator />
|
||||
<p class="text-xs leading-relaxed">
|
||||
LibreHardwareMonitor 访问 SMBus、部分 EC 传感器、某些 GPU 传感器需要管理员权限。
|
||||
默认非提权运行,覆盖大部分 CPU/GPU 温度(通过 OHM RPC/WMI 仍可读),牺牲部分主板/电压传感器。
|
||||
</p>
|
||||
<p class="text-xs leading-relaxed text-orange-600 dark:text-orange-400 flex items-start gap-1.5">
|
||||
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
|
||||
<span>提权策略(任务计划程序免 UAC 启动)将在阶段五实现,当前以普通权限运行。</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 关于 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<Clock class="size-4 text-primary" />关于
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm space-y-2 text-muted-foreground">
|
||||
<p class="text-xs leading-relaxed">
|
||||
数据源:LibreHardwareMonitorLib 0.9.5(Native AOT 编译,独立进程)
|
||||
</p>
|
||||
<p class="text-xs leading-relaxed">
|
||||
通信:HTTP + SSE(本地 loopback),schemaVersion=1 数据契约
|
||||
</p>
|
||||
<p v-if="store.snapshot?.coldStartMs" class="text-xs leading-relaxed">
|
||||
冷启动耗时:{{ (store.snapshot.coldStartMs / 1000).toFixed(1) }} 秒
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
@@ -18,5 +19,32 @@ export const moduleConfig: ModuleConfig = {
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./MonitorModule.vue'),
|
||||
searchItems,
|
||||
// 进程由 MonitorKernel 通过 ProcessManager 统一管理(id='monitor'),
|
||||
// executable/args 在运行时由后端 monitor_start 确定,此处仅声明 hasProcess 以便禁用时自动停止。
|
||||
process: {
|
||||
name: 'ThingHK',
|
||||
executable: '',
|
||||
autoStart: false,
|
||||
restartOnCrash: true,
|
||||
maxRestarts: 3
|
||||
},
|
||||
lifecycle: {
|
||||
onEnable: async () => {
|
||||
// 启用模块时拉起 Kernel 并开始 SSE 订阅
|
||||
try {
|
||||
await invoke('monitor_start')
|
||||
} catch {
|
||||
/* 忽略:可能 Kernel 未安装 */
|
||||
}
|
||||
},
|
||||
onDisable: async () => {
|
||||
// 禁用模块时停止 SSE 订阅并终止 Kernel 进程
|
||||
try {
|
||||
await invoke('monitor_stop')
|
||||
} catch {
|
||||
/* 忽略:可能 Kernel 未运行 */
|
||||
}
|
||||
}
|
||||
},
|
||||
order: 40
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('monitor')
|
||||
|
||||
// ===== 与 Rust 端 / C# Contracts.cs 对应的数据结构(camelCase) =====
|
||||
// schemaVersion=1 契约由 Kernel 维护,前端按 schemaVersion 解析。
|
||||
|
||||
export interface SensorEntry {
|
||||
id: string
|
||||
name: string
|
||||
/** 传感器类型(LHB SensorType 小写):temperature/load/power/voltage/fan/clock/data/smalldata/throughput/level/control 等 */
|
||||
type: string
|
||||
hardwareName: string
|
||||
/** null 表示首轮未就绪或硬件不可读 */
|
||||
value: number | null
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface SensorGroup {
|
||||
id: string
|
||||
name: string
|
||||
sensors: SensorEntry[]
|
||||
}
|
||||
|
||||
export interface SensorSnapshot {
|
||||
schemaVersion: number
|
||||
timestamp: number
|
||||
/** 仅首个快照有意义,后续为 0 */
|
||||
coldStartMs?: number
|
||||
isAdmin: boolean
|
||||
ready: boolean
|
||||
groups: SensorGroup[]
|
||||
}
|
||||
|
||||
export interface MonitorStatus {
|
||||
running: boolean
|
||||
pid: number | null
|
||||
ready: boolean
|
||||
sensorCount: number
|
||||
restartCount: number
|
||||
}
|
||||
|
||||
export interface MonitorKernelInfo {
|
||||
path: string
|
||||
exists: boolean
|
||||
port: number
|
||||
}
|
||||
|
||||
/** 连接状态机:与后端事件一一对应 */
|
||||
export type ConnectionState = 'idle' | 'loading' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
/** 5 秒未收到 monitor-data 事件视为掉线(与后端心跳节奏一致) */
|
||||
const STALE_TIMEOUT_MS = 5000
|
||||
|
||||
export const useMonitorStore = defineStore('monitor', () => {
|
||||
// ===== state =====
|
||||
const status = ref<MonitorStatus | null>(null)
|
||||
const snapshot = ref<SensorSnapshot | null>(null)
|
||||
const kernelInfo = ref<MonitorKernelInfo | null>(null)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
/** 累计收到的 monitor-data 事件数,用于诊断与"已连接"判定 */
|
||||
const eventCount = ref(0)
|
||||
/** 最近一次收到 monitor-data 的时间戳(ms) */
|
||||
const lastEventTime = ref(0)
|
||||
/** 是否正在启动 / 停止 Kernel(防止重复点击) */
|
||||
const starting = ref(false)
|
||||
const stopping = ref(false)
|
||||
|
||||
/** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */
|
||||
const initialized = ref(false)
|
||||
|
||||
let unlistenFns: UnlistenFn[] = []
|
||||
|
||||
// ===== getters =====
|
||||
|
||||
/** 当前连接状态(基于 status + 最近事件时间推断) */
|
||||
const connState = computed<ConnectionState>(() => {
|
||||
if (errorMsg.value) return 'error'
|
||||
if (!status.value) return 'idle'
|
||||
if (!status.value.running) return 'idle'
|
||||
if (!status.value.ready) return 'loading'
|
||||
if (eventCount.value === 0) return 'loading'
|
||||
if (Date.now() - lastEventTime.value > STALE_TIMEOUT_MS) return 'disconnected'
|
||||
return 'connected'
|
||||
})
|
||||
|
||||
/** 是否处于"已就绪 + 收到数据"的健康状态 */
|
||||
const isLive = computed(() => connState.value === 'connected')
|
||||
|
||||
/** 按分组 id 查找快照 */
|
||||
const groupById = computed(() => {
|
||||
const map: Record<string, SensorGroup> = {}
|
||||
for (const g of snapshot.value?.groups ?? []) map[g.id] = g
|
||||
return map
|
||||
})
|
||||
|
||||
/**
|
||||
* 在指定分组下查找首个匹配的传感器值。
|
||||
* @param groupId 分组 id(cpu/memory/gpuintel/storage 等)
|
||||
* @param matcher 传感器名匹配(精确或子串)
|
||||
*/
|
||||
function findSensorValue(groupId: string, matcher: { name?: string; hardwareName?: string; type?: string }): number | null {
|
||||
const g = groupById.value[groupId]
|
||||
if (!g) return null
|
||||
const s = g.sensors.find(s =>
|
||||
(!matcher.name || s.name === matcher.name || s.name.includes(matcher.name)) &&
|
||||
(!matcher.hardwareName || s.hardwareName === matcher.hardwareName || s.hardwareName.includes(matcher.hardwareName)) &&
|
||||
(!matcher.type || s.type === matcher.type)
|
||||
)
|
||||
return s?.value ?? null
|
||||
}
|
||||
|
||||
// ===== actions =====
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
status.value = await invoke<MonitorStatus>('monitor_status')
|
||||
errorMsg.value = null
|
||||
} catch (e) {
|
||||
logger.error('获取状态失败: ' + e)
|
||||
} finally {
|
||||
initialized.value = true
|
||||
}
|
||||
return status.value
|
||||
}
|
||||
|
||||
async function refreshKernelInfo() {
|
||||
try {
|
||||
kernelInfo.value = await invoke<MonitorKernelInfo>('monitor_kernel_info')
|
||||
} catch (e) {
|
||||
logger.error('获取 Kernel 信息失败: ' + e)
|
||||
}
|
||||
return kernelInfo.value
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (starting.value) return
|
||||
starting.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await invoke('monitor_start')
|
||||
await refreshStatus()
|
||||
} catch (e) {
|
||||
errorMsg.value = String(e)
|
||||
logger.error('启动失败: ' + e)
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (stopping.value) return
|
||||
stopping.value = true
|
||||
try {
|
||||
await invoke('monitor_stop')
|
||||
snapshot.value = null
|
||||
eventCount.value = 0
|
||||
lastEventTime.value = 0
|
||||
await refreshStatus()
|
||||
} catch (e) {
|
||||
errorMsg.value = String(e)
|
||||
logger.error('停止失败: ' + e)
|
||||
} finally {
|
||||
stopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 主动拉取一次性快照(切回 tab 时立即填充,不等下一个 SSE tick) */
|
||||
async function fetchSnapshot() {
|
||||
try {
|
||||
snapshot.value = await invoke<SensorSnapshot>('monitor_get_snapshot')
|
||||
} catch (e) {
|
||||
logger.error('拉取快照失败: ' + e)
|
||||
}
|
||||
return snapshot.value
|
||||
}
|
||||
|
||||
/** 订阅 Tauri 事件:monitor-data / monitor-ready / monitor-loading / monitor-disconnected / monitor-error */
|
||||
async function subscribe() {
|
||||
if (unlistenFns.length) return
|
||||
unlistenFns.push(await listen<SensorSnapshot>('monitor-data', (e) => {
|
||||
// schemaVersion 守卫:仅接受 v1,未来版本需在此处显式升级
|
||||
if (e.payload?.schemaVersion !== 1) {
|
||||
logger.warn('收到未知 schemaVersion: ' + e.payload?.schemaVersion)
|
||||
return
|
||||
}
|
||||
snapshot.value = e.payload
|
||||
eventCount.value++
|
||||
lastEventTime.value = Date.now()
|
||||
}))
|
||||
unlistenFns.push(await listen('monitor-ready', () => {
|
||||
refreshStatus()
|
||||
}))
|
||||
unlistenFns.push(await listen('monitor-loading', () => {
|
||||
// 状态由 status 轮询反映
|
||||
}))
|
||||
unlistenFns.push(await listen('monitor-disconnected', () => {
|
||||
logger.warn('SSE 断开,等待自动重连')
|
||||
refreshStatus()
|
||||
}))
|
||||
unlistenFns.push(await listen<{ message?: string }>('monitor-error', (e) => {
|
||||
errorMsg.value = e.payload?.message ?? 'Kernel 错误'
|
||||
logger.error('Kernel 错误: ' + JSON.stringify(e.payload))
|
||||
}))
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
unlistenFns.forEach(fn => fn())
|
||||
unlistenFns = []
|
||||
}
|
||||
|
||||
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照 */
|
||||
async function init() {
|
||||
await Promise.all([refreshStatus(), refreshKernelInfo()])
|
||||
await subscribe()
|
||||
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
|
||||
if (status.value?.ready) {
|
||||
await fetchSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/** 模块卸载时调用:仅取消事件订阅,不停止 Kernel(Kernel 由 ProcessManager 全局管理) */
|
||||
function dispose() {
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
status,
|
||||
snapshot,
|
||||
kernelInfo,
|
||||
errorMsg,
|
||||
eventCount,
|
||||
lastEventTime,
|
||||
starting,
|
||||
stopping,
|
||||
initialized,
|
||||
// getters
|
||||
connState,
|
||||
isLive,
|
||||
groupById,
|
||||
// actions
|
||||
findSensorValue,
|
||||
refreshStatus,
|
||||
refreshKernelInfo,
|
||||
start,
|
||||
stop,
|
||||
fetchSnapshot,
|
||||
init,
|
||||
dispose,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user