独立监控核心和模块
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
publish/
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ThingHK;
|
||||
|
||||
// ===== 数据契约(与 ThingHK_GUIDE.md 第 2.4 节一致,schemaVersion=1) =====
|
||||
// 这些类型同时用于:HTTP 响应、SSE 推送、--scan 命令输出。
|
||||
// 任何字段变更必须递增 SensorSnapshot.SchemaVersion。
|
||||
|
||||
/// <summary>
|
||||
/// 传感器快照:一次全量采样的标准化输出。
|
||||
/// 同时用于 GET /snapshot 响应、GET /stream SSE 事件、--scan 命令输出。
|
||||
/// </summary>
|
||||
internal sealed class SensorSnapshot
|
||||
{
|
||||
/// <summary>数据契约版本, breaking 变更时递增</summary>
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>采样时间戳(UTC 毫秒)</summary>
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
/// <summary>从 Kernel 启动到首次产生快照的毫秒数(仅首个快照有意义,后续为 0)</summary>
|
||||
public double ColdStartMs { get; set; }
|
||||
|
||||
/// <summary>Kernel 是否以管理员权限运行</summary>
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
/// <summary>当前 Kernel 是否已完成首轮扫描(ready 信号依据)</summary>
|
||||
public bool Ready { get; set; }
|
||||
|
||||
/// <summary>本次快照包含的硬件分组</summary>
|
||||
public List<SensorGroup> Groups { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class SensorGroup
|
||||
{
|
||||
/// <summary>分组标识(hardware type 小写,如 cpu/gpuintel/storage)</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>分组显示名(如 CPU / GpuIntel)</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>该分组下的传感器列表</summary>
|
||||
public List<SensorEntry> Sensors { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class SensorEntry
|
||||
{
|
||||
/// <summary>全局唯一 ID:{groupId}/{hwName}/{sensorType}/{sensorName} 小写化</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string HardwareName { get; set; } = "";
|
||||
|
||||
/// <summary>传感器当前值;null 表示首轮未就绪或硬件不可读</summary>
|
||||
public float? Value { get; set; }
|
||||
|
||||
public string Unit { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /status 路由响应:用于冷启动就绪探测(见指南第 3.1 节)。
|
||||
/// 前端通过轮询此接口判断是否可请求 /snapshot 或订阅 /stream。
|
||||
/// </summary>
|
||||
internal sealed class KernelStatus
|
||||
{
|
||||
public bool Ready { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
public double UptimeMs { get; set; }
|
||||
public int GroupCount { get; set; }
|
||||
public int SensorCount { get; set; }
|
||||
public List<string> Providers { get; set; } = new();
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /config 请求体:运行时调整采样配置。
|
||||
/// 字段均可选,仅传递需要变更的字段。
|
||||
/// </summary>
|
||||
internal sealed class ConfigRequest
|
||||
{
|
||||
/// <summary>快通道采样间隔(毫秒),0 或负数表示不变</summary>
|
||||
public int? FastIntervalMs { get; set; }
|
||||
|
||||
/// <summary>慢通道采样间隔(毫秒),0 或负数表示不变</summary>
|
||||
public int? SlowIntervalMs { get; set; }
|
||||
|
||||
/// <summary>SSE 推送间隔(毫秒),仅影响后续 stream 订阅,0 或负数表示不变</summary>
|
||||
public int? StreamIntervalMs { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ConfigResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public int FastIntervalMs { get; set; }
|
||||
public int SlowIntervalMs { get; set; }
|
||||
public int StreamIntervalMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根路由健康检查响应。
|
||||
/// </summary>
|
||||
internal sealed class HealthResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Name { get; set; } = "ThingHK";
|
||||
public string Version { get; set; } = "0.2.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误响应:AOT 下所有 BadRequest/NotFound 必须用强类型,不能用匿名对象。
|
||||
/// </summary>
|
||||
internal sealed class ErrorResponse
|
||||
{
|
||||
public ErrorResponse(string error, string message) { Error = error; Message = message; }
|
||||
public string Error { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON 源生成上下文:消除 Native AOT 下反射式序列化的 IL3050/IL2026 警告。
|
||||
/// 所有 HTTP 响应类型必须在此注册,否则 AOT 下序列化会抛异常。
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(SensorSnapshot))]
|
||||
[JsonSerializable(typeof(KernelStatus))]
|
||||
[JsonSerializable(typeof(ConfigRequest))]
|
||||
[JsonSerializable(typeof(ConfigResponse))]
|
||||
[JsonSerializable(typeof(ConfigRequest[]))]
|
||||
[JsonSerializable(typeof(HealthResponse))]
|
||||
[JsonSerializable(typeof(ErrorResponse))]
|
||||
internal sealed partial class ThingHKJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,381 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using LibreHardwareMonitor.Hardware;
|
||||
|
||||
namespace ThingHK;
|
||||
|
||||
/// <summary>
|
||||
/// 硬件访问层:封装 LibreHardwareMonitor Computer,
|
||||
/// 负责硬件发现、分层 Update(快/慢通道)、快照构建。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - Computer 非 IDisposable(LHB 0.9.5),用 Close() 显式释放
|
||||
/// - 传感器按 SensorType 分快/慢通道:
|
||||
/// * 快通道(temp/load/clock/power/voltage/throughput):高频,1s
|
||||
/// * 慢通道(data/smalldata/factor/level/control/timing/energy 等):低频,5s
|
||||
/// 原因:SMART 查询会阻塞,混在一起会拖慢整体;data 类(如内存使用量)变化缓慢
|
||||
/// - Update 只作用于硬件设备,传感器读取由 visitor 遍历收集(LHB 设计)
|
||||
/// </summary>
|
||||
internal sealed class HardwareManager : IDisposable
|
||||
{
|
||||
private readonly Computer _computer;
|
||||
private readonly SnapshotVisitor _visitor = new();
|
||||
private readonly bool _isAdmin;
|
||||
private readonly double _coldStartMs;
|
||||
private readonly Stopwatch _startupSw;
|
||||
private bool _ready;
|
||||
private bool _closed;
|
||||
|
||||
// 缓存"哪个硬件属于快通道"——按 hardware type 判断更稳定(不同机型传感器命名不一致)
|
||||
// 实际分频逻辑见 SamplingScheduler,此处只暴露硬件列表给调度器
|
||||
public IReadOnlyList<IHardware> AllHardware => _visitor.AllHardware;
|
||||
|
||||
public bool IsAdmin => _isAdmin;
|
||||
public double ColdStartMs => _coldStartMs;
|
||||
public bool Ready => _ready;
|
||||
|
||||
public HardwareManager(bool basic)
|
||||
{
|
||||
_isAdmin = IsRunningAsAdmin();
|
||||
_startupSw = Stopwatch.StartNew();
|
||||
|
||||
_computer = new Computer
|
||||
{
|
||||
IsCpuEnabled = true,
|
||||
IsGpuEnabled = true,
|
||||
IsMemoryEnabled = true,
|
||||
IsStorageEnabled = true,
|
||||
// basic 模式只开核心四类硬件(CPU/GPU/RAM/Storage),降低冷启动耗时
|
||||
IsMotherboardEnabled = !basic,
|
||||
IsControllerEnabled = !basic,
|
||||
IsBatteryEnabled = !basic,
|
||||
IsNetworkEnabled = !basic,
|
||||
IsPsuEnabled = !basic,
|
||||
};
|
||||
|
||||
_computer.Open();
|
||||
|
||||
// 首轮扫描:发现所有硬件(Update 前的 Sensors 通常为空,但 Hardware 列表就绪)
|
||||
_computer.Accept(_visitor);
|
||||
|
||||
// 首轮 Update:填充传感器值
|
||||
// 注意:部分传感器第二轮才有值,调度器会持续 Update
|
||||
UpdateAll();
|
||||
|
||||
_coldStartMs = _startupSw.Elapsed.TotalMilliseconds;
|
||||
_startupSw.Stop();
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全量 Update 所有硬件。
|
||||
/// 由 SamplingScheduler 按通道分频调用。
|
||||
/// </summary>
|
||||
public void UpdateAll()
|
||||
{
|
||||
foreach (var hw in _visitor.AllHardware)
|
||||
{
|
||||
try { hw.Update(); } catch { /* 单个硬件 Update 失败不影响整体 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。
|
||||
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateAll。
|
||||
/// </summary>
|
||||
public void UpdateSlowOnly()
|
||||
{
|
||||
foreach (var hw in _visitor.AllHardware)
|
||||
{
|
||||
if (IsSlowHardware(hw.HardwareType))
|
||||
{
|
||||
try { hw.Update(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建 SensorSnapshot:遍历当前所有传感器,不触发 Update。
|
||||
/// 调用者应先 Update 再 Snapshot,避免读到旧值。
|
||||
/// </summary>
|
||||
public SensorSnapshot BuildSnapshot()
|
||||
{
|
||||
var snap = new SensorSnapshot
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
IsAdmin = _isAdmin,
|
||||
Ready = _ready,
|
||||
};
|
||||
|
||||
// 首个快照带上冷启动耗时,后续为 0
|
||||
if (_coldStartMs > 0 && snap.Timestamp > 0)
|
||||
{
|
||||
snap.ColdStartMs = Math.Round(_coldStartMs, 1);
|
||||
}
|
||||
|
||||
// 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时)
|
||||
var groups = new Dictionary<string, SensorGroup>();
|
||||
foreach (var hw in _visitor.AllHardware)
|
||||
{
|
||||
string groupId = hw.HardwareType.ToString().ToLowerInvariant();
|
||||
string groupName = hw.HardwareType.ToString();
|
||||
if (!groups.TryGetValue(groupId, out var g))
|
||||
{
|
||||
g = new SensorGroup { Id = groupId, Name = groupName };
|
||||
groups[groupId] = g;
|
||||
snap.Groups.Add(g);
|
||||
}
|
||||
|
||||
foreach (var s in hw.Sensors)
|
||||
{
|
||||
g.Sensors.Add(new SensorEntry
|
||||
{
|
||||
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
|
||||
Name = s.Name,
|
||||
Type = s.SensorType.ToString().ToLowerInvariant(),
|
||||
Value = s.Value,
|
||||
Unit = UnitFor(s.SensorType),
|
||||
HardwareName = hw.Name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return snap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断硬件是否属于慢通道(低频 Update 即可)。
|
||||
/// 快通道:CPU/GPU/Memory/Network(变化快、查询轻量)
|
||||
/// 慢通道:Storage/PSU/Motherboard/Battery/SuperIO/EmbeddedController(查询重或变化慢)
|
||||
/// </summary>
|
||||
public static bool IsSlowHardware(HardwareType t) => t switch
|
||||
{
|
||||
HardwareType.Storage => true,
|
||||
HardwareType.Psu => true,
|
||||
HardwareType.Motherboard => true,
|
||||
HardwareType.Battery => true,
|
||||
HardwareType.SuperIO => true,
|
||||
HardwareType.EmbeddedController => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static string UnitFor(SensorType t) => t switch
|
||||
{
|
||||
SensorType.Temperature => "°C",
|
||||
SensorType.Load => "%",
|
||||
SensorType.Power => "W",
|
||||
SensorType.Voltage => "V",
|
||||
SensorType.Fan => "RPM",
|
||||
SensorType.Clock => "MHz",
|
||||
SensorType.Data => "GB",
|
||||
SensorType.SmallData => "MB",
|
||||
SensorType.Frequency => "Hz",
|
||||
SensorType.Throughput => "B/s",
|
||||
SensorType.Level => "%",
|
||||
SensorType.Factor => "",
|
||||
SensorType.Control => "%",
|
||||
SensorType.Flow => "L/h",
|
||||
SensorType.TimeSpan => "s",
|
||||
SensorType.Energy => "mWh",
|
||||
SensorType.Noise => "dBA",
|
||||
SensorType.Conductivity => "µS/cm",
|
||||
SensorType.Humidity => "%",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static bool IsRunningAsAdmin()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try { _computer.Close(); } catch { /* Close 在 AOT 下偶有反射清理异常 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 遍历 Computer,收集所有 Hardware(含 SubHardware)。
|
||||
/// LHB 的 Visit 会递归到 SubHardware。
|
||||
/// 引用缓存:visitor 持有 hardware 引用,传感器值通过 hardware.Sensors 实时读取。
|
||||
/// </summary>
|
||||
internal sealed class SnapshotVisitor : IVisitor
|
||||
{
|
||||
public List<IHardware> AllHardware { get; } = new();
|
||||
|
||||
public void VisitComputer(IComputer computer)
|
||||
{
|
||||
foreach (var hw in computer.Hardware) hw.Accept(this);
|
||||
}
|
||||
|
||||
public void VisitHardware(IHardware hardware)
|
||||
{
|
||||
AllHardware.Add(hardware);
|
||||
foreach (var sub in hardware.SubHardware) sub.Accept(this);
|
||||
}
|
||||
|
||||
public void VisitSensor(ISensor sensor) { }
|
||||
public void VisitParameter(IParameter parameter) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 采样调度器:按快/慢通道分频驱动 HardwareManager.UpdateAll。
|
||||
/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。
|
||||
///
|
||||
/// 调度策略:
|
||||
/// - 快通道 tick:UpdateAll(含慢通道硬件,因 UpdateAll 成本主要在 SMART,已通过慢通道分频减少调用频率)
|
||||
/// 实际优化:快通道 tick 只 Update 快通道硬件(UpdateFastOnly),慢通道单独按慢节奏 Update
|
||||
/// - 慢通道 tick:UpdateSlowOnly(仅 Storage/PSU/Motherboard 等)
|
||||
/// - 每个 tick 结束后构建快照并广播
|
||||
/// </summary>
|
||||
internal sealed class SamplingScheduler : IDisposable
|
||||
{
|
||||
private readonly HardwareManager _hw;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Channel<SensorSnapshot> _broadcast;
|
||||
private readonly SnapshotCache _cache;
|
||||
|
||||
private int _fastIntervalMs = 1000;
|
||||
private int _slowIntervalMs = 5000;
|
||||
|
||||
public SnapshotCache Cache => _cache;
|
||||
public int FastIntervalMs => _fastIntervalMs;
|
||||
public int SlowIntervalMs => _slowIntervalMs;
|
||||
|
||||
public SamplingScheduler(HardwareManager hw, int fastIntervalMs, int slowIntervalMs)
|
||||
{
|
||||
_hw = hw;
|
||||
_fastIntervalMs = Math.Max(200, fastIntervalMs);
|
||||
_slowIntervalMs = Math.Max(_fastIntervalMs, slowIntervalMs);
|
||||
// unbounded channel:快照丢失风险 < 内存爆涨风险(消费慢时丢弃最旧的)
|
||||
// 这里用 bounded + DropOldest:保证 SSE 慢消费者不阻塞调度器
|
||||
_broadcast = Channel.CreateBounded<SensorSnapshot>(new BoundedChannelOptions(8)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = false,
|
||||
SingleWriter = true,
|
||||
});
|
||||
_cache = new SnapshotCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动调度循环。立即推送首个快照(已由 HardwareManager 构造时 Update 过)。
|
||||
/// </summary>
|
||||
public Task StartAsync()
|
||||
{
|
||||
// 首个快照(含 ColdStartMs)
|
||||
var first = _hw.BuildSnapshot();
|
||||
_cache.Update(first);
|
||||
_broadcast.Writer.TryWrite(first);
|
||||
|
||||
// 快/慢通道并行循环
|
||||
_ = Task.Run(() => FastLoopAsync(_cts.Token));
|
||||
_ = Task.Run(() => SlowLoopAsync(_cts.Token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task FastLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_fastIntervalMs));
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
_hw.UpdateAll();
|
||||
var snap = _hw.BuildSnapshot();
|
||||
_cache.Update(snap);
|
||||
_broadcast.Writer.TryWrite(snap);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[Scheduler] 快通道异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SlowLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_slowIntervalMs));
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
// 慢通道单独 Update,避免依赖快通道的 UpdateAll
|
||||
// 注:下一次快通道 tick 构建的快照会包含本次慢通道更新后的值
|
||||
_hw.UpdateSlowOnly();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[Scheduler] 慢通道异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅快照流。每个 SSE 客户端调用一次。
|
||||
/// 返回的 IAsyncEnumerable 会在调度器停止或订阅者取消时结束。
|
||||
/// </summary>
|
||||
public IAsyncEnumerable<SensorSnapshot> SubscribeAsync(CancellationToken ct) =>
|
||||
_broadcast.Reader.ReadAllAsync(ct);
|
||||
|
||||
public void UpdateIntervals(int? fastMs, int? slowMs)
|
||||
{
|
||||
// 注意:PeriodicTimer 已启动后无法修改间隔,下次重启 Kernel 才生效。
|
||||
// 阶段二简化处理:记录新值,实际生效需重启。阶段三若需热更新可重建 timer。
|
||||
if (fastMs.HasValue && fastMs.Value >= 200) _fastIntervalMs = fastMs.Value;
|
||||
if (slowMs.HasValue && slowMs.Value >= _fastIntervalMs) _slowIntervalMs = slowMs.Value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_broadcast.Writer.TryComplete();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 快照缓存:存储最新快照,供 GET /snapshot 直接返回,避免触发底层重扫描。
|
||||
/// 线程安全:读写均加锁,快照对象本身不可变(每次 Update 替换引用)。
|
||||
/// </summary>
|
||||
internal sealed class SnapshotCache
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private SensorSnapshot? _latest;
|
||||
private int _sensorCount;
|
||||
|
||||
public void Update(SensorSnapshot snap)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_latest = snap;
|
||||
_sensorCount = snap.Groups.Sum(g => g.Sensors.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public SensorSnapshot? GetLatest()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _latest;
|
||||
}
|
||||
}
|
||||
|
||||
public int SensorCount
|
||||
{
|
||||
get { lock (_lock) return _sensorCount; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ThingHK;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 服务层:minimal API 风格扩展方法。
|
||||
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
|
||||
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
|
||||
/// GET /snapshot → 一次性拉取最新缓存快照
|
||||
/// GET /stream → SSE 订阅,Kernel 主动推送 SensorSnapshot
|
||||
/// POST /config → 运行时调整采样间隔
|
||||
/// GET / → 健康检查(200 OK)
|
||||
/// </summary>
|
||||
internal static class HttpEndpoints
|
||||
{
|
||||
public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel)
|
||||
{
|
||||
// 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
try { await next(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[HTTP] {ctx.Request.Method} {ctx.Request.Path} 异常: {ex}");
|
||||
ctx.Response.StatusCode = 500;
|
||||
await ctx.Response.WriteAsync($"{{\"error\":\"internal\",\"message\":\"{ex.Message.Replace("\"", "\\\"")}\"}}");
|
||||
}
|
||||
});
|
||||
|
||||
// 根路由:进程存活检查(curl 友好)
|
||||
// AOT 下必须用 Results.Json + TypeInfo,Results.Ok(object) 会走默认 options 抛 JsonTypeInfo 异常
|
||||
app.MapGet("/", () => Results.Json(new HealthResponse(), ThingHKJsonContext.Default.HealthResponse));
|
||||
|
||||
// 冷启动就绪探测
|
||||
app.MapGet("/status", () =>
|
||||
{
|
||||
var hw = kernel.Hardware;
|
||||
var snap = kernel.Scheduler.Cache.GetLatest();
|
||||
var status = new KernelStatus
|
||||
{
|
||||
Ready = hw?.Ready ?? false,
|
||||
IsAdmin = hw?.IsAdmin ?? false,
|
||||
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
||||
GroupCount = snap?.Groups.Count ?? 0,
|
||||
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
||||
Providers = snap?.Groups.Select(g => g.Id).ToList() ?? new List<string>(),
|
||||
};
|
||||
return Results.Json(status, ThingHKJsonContext.Default.KernelStatus);
|
||||
});
|
||||
|
||||
// 一次性快照
|
||||
app.MapGet("/snapshot", (HttpContext ctx) =>
|
||||
{
|
||||
var snap = kernel.Scheduler.Cache.GetLatest();
|
||||
if (snap == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
return Results.Json(new ErrorResponse("not_ready", "Kernel 尚未完成首轮扫描"), ThingHKJsonContext.Default.ErrorResponse);
|
||||
}
|
||||
return Results.Json(snap, ThingHKJsonContext.Default.SensorSnapshot);
|
||||
});
|
||||
|
||||
// SSE 推送流
|
||||
app.MapGet("/stream", async (HttpContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
// 强制 text/event-stream,禁用响应缓冲(SSE 必须 flush)
|
||||
ctx.Response.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
// 先推一次当前缓存快照,避免客户端等待一个 tick 才有数据
|
||||
var initial = kernel.Scheduler.Cache.GetLatest();
|
||||
if (initial != null)
|
||||
{
|
||||
await WriteSseEventAsync(ctx, initial, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 订阅广播
|
||||
await foreach (var snap in kernel.Scheduler.SubscribeAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
await WriteSseEventAsync(ctx, snap, ct).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 运行时配置调整
|
||||
// 注意:async lambda 必须返回 Task<IResult>,单纯返回 IResult 会被框架当成 Task<IResult> 的未等待任务,导致响应体为空
|
||||
app.MapPost("/config", async Task<IResult> (HttpContext ctx) =>
|
||||
{
|
||||
// 先读 body 为字符串,再反序列化。
|
||||
// 在 AOT 下比 DeserializeAsync 对 nullable 属性更稳定。
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync(ctx.RequestAborted);
|
||||
ConfigRequest? req;
|
||||
try
|
||||
{
|
||||
req = string.IsNullOrWhiteSpace(body)
|
||||
? new ConfigRequest()
|
||||
: JsonSerializer.Deserialize(body, ThingHKJsonContext.Default.ConfigRequest);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
return Results.Json(new ErrorResponse("invalid_json", ex.Message), ThingHKJsonContext.Default.ErrorResponse);
|
||||
}
|
||||
if (req == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
return Results.Json(new ErrorResponse("invalid_body", "请求体为空"), ThingHKJsonContext.Default.ErrorResponse);
|
||||
}
|
||||
|
||||
kernel.Scheduler.UpdateIntervals(req.FastIntervalMs, req.SlowIntervalMs);
|
||||
return Results.Json(new ConfigResponse
|
||||
{
|
||||
Success = true,
|
||||
FastIntervalMs = kernel.Scheduler.FastIntervalMs,
|
||||
SlowIntervalMs = kernel.Scheduler.SlowIntervalMs,
|
||||
StreamIntervalMs = kernel.Scheduler.FastIntervalMs,
|
||||
}, ThingHKJsonContext.Default.ConfigResponse);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写一条 SSE 事件:event: snapshot\ndata: {json}\n\n
|
||||
/// </summary>
|
||||
private static async Task WriteSseEventAsync(HttpContext ctx, SensorSnapshot snap, CancellationToken ct)
|
||||
{
|
||||
await ctx.Response.WriteAsync("event: snapshot\n", ct).ConfigureAwait(false);
|
||||
await ctx.Response.WriteAsync("data: ", ct).ConfigureAwait(false);
|
||||
// 直接用流式序列化,避免大字符串分配
|
||||
await JsonSerializer.SerializeAsync(
|
||||
ctx.Response.Body, snap, ThingHKJsonContext.Default.SensorSnapshot, ct).ConfigureAwait(false);
|
||||
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
|
||||
await ctx.Response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kernel 宿主:持有 HardwareManager + SamplingScheduler,管理整体生命周期。
|
||||
/// Program.cs 构造此对象并注入到 DI 容器供 endpoint 使用。
|
||||
/// </summary>
|
||||
internal sealed class KernelHost : IDisposable
|
||||
{
|
||||
public HardwareManager? Hardware { get; private set; }
|
||||
public SamplingScheduler Scheduler { get; private set; } = null!;
|
||||
public Stopwatch Uptime { get; } = Stopwatch.StartNew();
|
||||
|
||||
public async Task StartAsync(bool basic, int fastMs, int slowMs)
|
||||
{
|
||||
// 1. 初始化硬件层(含首轮 Update,约 5 秒)
|
||||
Hardware = new HardwareManager(basic);
|
||||
Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}");
|
||||
|
||||
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
|
||||
Scheduler = new SamplingScheduler(Hardware, fastMs, slowMs);
|
||||
await Scheduler.StartAsync();
|
||||
Console.Error.WriteLine($"[ThingHK] 调度器已启动: fast={Scheduler.FastIntervalMs}ms slow={Scheduler.SlowIntervalMs}ms");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Console.Error.WriteLine("[ThingHK] Kernel 正在关闭...");
|
||||
Scheduler?.Dispose();
|
||||
Hardware?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.CommandLine;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ThingHK;
|
||||
|
||||
/// <summary>
|
||||
/// ThingHK 入口。
|
||||
///
|
||||
/// 子命令:
|
||||
/// serve (默认) 启动 HTTP 服务,暴露 /status /snapshot /stream /config
|
||||
/// scan 阶段一的覆盖矩阵测试(保留以便复测)
|
||||
///
|
||||
/// 默认(无参数)等价于 serve --port 8730 --basic。
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const int DefaultPort = 8730;
|
||||
private const int DefaultFastMs = 1000;
|
||||
private const int DefaultSlowMs = 5000;
|
||||
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
// 无参数 → 默认 serve(最常见用法)
|
||||
if (args.Length == 0)
|
||||
{
|
||||
return await ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs);
|
||||
}
|
||||
|
||||
// serve 子命令
|
||||
var serveCmd = new Command("serve", "启动 HTTP 服务");
|
||||
var portOpt = new Option<int>("--port", () => DefaultPort, "监听端口");
|
||||
var basicOpt = new Option<bool>("--basic", "仅启用核心四类硬件,降低冷启动耗时");
|
||||
var fullOpt = new Option<bool>("--full", "启用全部硬件(含主板/控制器/网络等)");
|
||||
var fastOpt = new Option<int>("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)");
|
||||
var slowOpt = new Option<int>("--slow-ms", () => DefaultSlowMs, "慢通道采样间隔(毫秒)");
|
||||
serveCmd.AddOption(portOpt);
|
||||
serveCmd.AddOption(basicOpt);
|
||||
serveCmd.AddOption(fullOpt);
|
||||
serveCmd.AddOption(fastOpt);
|
||||
serveCmd.AddOption(slowOpt);
|
||||
serveCmd.SetHandler((port, basic, full, fast, slow) =>
|
||||
ServeAsync(port, basic || !full, fast, slow),
|
||||
portOpt, basicOpt, fullOpt, fastOpt, slowOpt);
|
||||
|
||||
// scan 子命令(阶段一覆盖矩阵测试,保留)
|
||||
var scanCmd = new Command("scan", "扫描一次并输出覆盖矩阵(阶段一验证用)");
|
||||
var scanJsonOpt = new Option<bool>("--json", "JSON 输出");
|
||||
var scanBasicOpt = new Option<bool>("--basic", "仅核心四类硬件");
|
||||
scanCmd.AddOption(scanJsonOpt);
|
||||
scanCmd.AddOption(scanBasicOpt);
|
||||
scanCmd.SetHandler((json, basic) => ScanAsync(json, basic), scanJsonOpt, scanBasicOpt);
|
||||
|
||||
var root = new RootCommand("ThingHardwareKernel - Thing 硬件监控内核");
|
||||
root.Add(serveCmd);
|
||||
root.Add(scanCmd);
|
||||
// 兼容阶段一的旧用法:ThingHK.exe --json --basic
|
||||
root.SetHandler(() => ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs));
|
||||
|
||||
return await root.InvokeAsync(args);
|
||||
}
|
||||
|
||||
private static async Task<int> ServeAsync(int port, bool basic, int fastMs, int slowMs)
|
||||
{
|
||||
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} basic={basic} fast={fastMs}ms slow={slowMs}ms");
|
||||
|
||||
using var kernel = new KernelHost();
|
||||
await kernel.StartAsync(basic, fastMs, slowMs);
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{port}/");
|
||||
|
||||
// 抑制 Kestrel 默认日志噪音
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddProvider(new StderrLoggerProvider());
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapThingHKEndpoints(kernel);
|
||||
|
||||
// Ctrl+C 优雅关闭
|
||||
var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel();
|
||||
|
||||
Console.Error.WriteLine($"[ThingHK] HTTP 服务监听: http://127.0.0.1:{port}/");
|
||||
Console.Error.WriteLine("[ThingHK] 路由: GET / GET /status GET /snapshot GET /stream POST /config");
|
||||
|
||||
try
|
||||
{
|
||||
await app.RunAsync(cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException) { /* 正常关闭 */ }
|
||||
Console.Error.WriteLine("[ThingHK] 已退出");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 阶段一覆盖矩阵测试入口(保留)。
|
||||
/// </summary>
|
||||
private static async Task<int> ScanAsync(bool json, bool basic)
|
||||
{
|
||||
Console.Error.WriteLine($"[ThingHK] scan 模式: json={json} basic={basic}");
|
||||
using var hw = new HardwareManager(basic);
|
||||
|
||||
// 第二轮 Update:部分传感器首轮才初始化
|
||||
hw.UpdateAll();
|
||||
var snap = hw.BuildSnapshot();
|
||||
|
||||
if (json)
|
||||
{
|
||||
// 阶段一 Report 字段已合并进 SensorSnapshot,复用 ColdStartMs
|
||||
Console.WriteLine(JsonSerializer.Serialize(snap, ThingHKJsonContext.Default.SensorSnapshot));
|
||||
}
|
||||
else
|
||||
{
|
||||
PrintText(snap, hw.ColdStartMs, hw.IsAdmin);
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
return snap.Groups.Count == 0 ? 3 : 0;
|
||||
}
|
||||
|
||||
private static void PrintText(SensorSnapshot r, double coldStartMs, bool isAdmin)
|
||||
{
|
||||
Console.WriteLine("==== ThingHK 覆盖矩阵 ====");
|
||||
Console.WriteLine($"管理员: {isAdmin} | 冷启动: {coldStartMs:F1} ms | .NET: {Environment.Version}");
|
||||
Console.WriteLine($"硬件分组数: {r.Groups.Count}");
|
||||
int total = 0, readable = 0;
|
||||
foreach (var g in r.Groups)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"[{g.Name}] ({g.Sensors.Count} 个传感器)");
|
||||
foreach (var s in g.Sensors)
|
||||
{
|
||||
total++;
|
||||
string val = s.Value.HasValue ? $"{s.Value.Value:F2}{(string.IsNullOrEmpty(s.Unit) ? "" : " " + s.Unit)}" : "N/A";
|
||||
if (s.Value.HasValue) readable++;
|
||||
Console.WriteLine($" - {s.Name,-32} {s.Type,-14} {val}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"==== 汇总: {readable}/{total} 个传感器有值 ====");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 ASP.NET Core 日志重定向到 stderr,避免污染 stdout(stdout 留给 /snapshot 等 JSON 响应)。
|
||||
/// </summary>
|
||||
internal sealed class StderrLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => new StderrLogger();
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class StderrLogger : ILogger
|
||||
{
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
|
||||
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? ex, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(level)) return;
|
||||
Console.Error.WriteLine($"[kestrel] {formatter(state, ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ThingHK</RootNamespace>
|
||||
<AssemblyName>ThingHK</AssemblyName>
|
||||
|
||||
<!-- Native AOT 配置 -->
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
|
||||
<!-- 反射兜底:LHB 内部大量使用反射,保留必要元数据 -->
|
||||
<TrimmerRootDescriptor>rd.xml</TrimmerRootDescriptor>
|
||||
|
||||
<!-- 关闭 CA1416 警告(项目明确仅面向 Windows) -->
|
||||
<NoWarn>$(NoWarn);CA1416</NoWarn>
|
||||
|
||||
<!-- 产物信息 -->
|
||||
<Version>0.2.0</Version>
|
||||
<Company>Thing</Company>
|
||||
<Product>ThingHardwareKernel</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,245 @@
|
||||
# ThingHK — Thing 硬件监控内核
|
||||
|
||||
## 1. 架构
|
||||
|
||||
采用**微内核架构**,将硬件监控功能剥离为独立进程 `ThingHK.exe`(C# + LibreHardwareMonitor)。Tauri 主进程(Rust)仅作为"消费者",通过本地 HTTP + SSE 获取标准化数据,实现硬件层与业务层彻底解耦。
|
||||
|
||||
**核心数据流**:
|
||||
|
||||
```
|
||||
Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程)
|
||||
↓ emit("monitor-data")
|
||||
前端 MonitorModule.vue
|
||||
```
|
||||
|
||||
**设计取舍**:
|
||||
|
||||
- **进程隔离而非进程内宿主**:LHB 或驱动崩溃不会拖垮 Tauri 主进程;独立进程 + Job Object 既隔离崩溃又复用项目现有架构。
|
||||
- **C# + LibreHardwareMonitor 选型**:Rust 侧硬件监控生态薄弱(`sysinfo` 无 GPU 温度/主板电压,`wmi` 慢且字段不全,自实现 NVAPI/ADL/SMBus 等于重写 LHB)。LHB 是业界事实标准,覆盖 CPU/GPU/盘/主板/USB,可靠性最高。
|
||||
- **复用 ProcessManager**:项目已有 [process_manager.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/process_manager.rs) 完整实现了生命周期管理(启动/停止/心跳/崩溃重启/Job Object),ThingHK 不重建这套逻辑,仅作为"数据消费者"。
|
||||
- **HTTP 而非 Named Pipe**:数据量极小(传感器快照每秒 1-2 次、几 KB 量级),Named Pipe 的性能优势用不上;与项目技术栈一致(Cargo.toml 已有 `reqwest`,mihomo 也走 HTTP REST);调试可用浏览器/curl;跨平台抽象免费。
|
||||
|
||||
## 2. 组件与职责
|
||||
|
||||
### 2.1 ThingHK 独立进程(C#)
|
||||
|
||||
- **角色**:硬件数据中台。
|
||||
- **核心组件**:
|
||||
- **数据采集层**:以 LibreHardwareMonitorLib 为唯一数据源(LHB 已统一封装 NVAPI/ADL/SMBus/SMART 等来源)。
|
||||
- **SamplingScheduler**:按传感器类型分层分频,避免慢查询拖累快通道。
|
||||
- 快通道(CPU/GPU/Memory/Network):`PeriodicTimer` 默认 1000ms,`UpdateAll()` 全量刷新
|
||||
- 慢通道(Storage/PSU/Motherboard/Battery/SuperIO/EmbeddedController):独立 `PeriodicTimer` 默认 5000ms,`UpdateSlowOnly()` 仅刷新慢硬件
|
||||
- 两通道并行运行,每次快通道 tick 后构建快照并广播到 `Channel<SensorSnapshot>`(bounded=8,DropOldest 防慢消费者阻塞)
|
||||
- **SnapshotCache**:线程安全快照缓存,UI 请求时直接返回缓存数据,避免触发底层硬件重扫描。
|
||||
- **HTTP 服务层**:ASP.NET Core minimal API,暴露 `127.0.0.1:8730` 本地 HTTP 服务。
|
||||
|
||||
### 2.2 Tauri 主进程(Rust)
|
||||
|
||||
- **角色**:管理者(复用 ProcessManager)与消费者。
|
||||
- **职责**:
|
||||
- **生命周期管理**:复用 `ProcessManager`,通过 `start_with_subscription` 拉起 Kernel。崩溃自动重启、Job Object 异常清理、`stop_all` 退出清理均已内置。
|
||||
- **数据接收**:作为 HTTP 客户端,轮询 `/status` 判就绪 → 订阅 `/stream` SSE → 解析事件 → `emit("monitor-data")`。
|
||||
- **写入熔断 + 自动重连**:SSE 断开后停止转发,3 秒退避后重试;同时监听 `process-status-changed` 事件,Kernel 由 ProcessManager 自动重启恢复 Running 后主动重新订阅 SSE。
|
||||
|
||||
### 2.3 前端(Vue 3)
|
||||
|
||||
- **Pinia store**([monitorStore.ts](file:///d:/Atie/Gitea/Thing/src/stores/monitorStore.ts)):管理 status/snapshot 状态,订阅 Tauri 事件,提供 `connState` 状态机(idle/loading/connected/disconnected/error)。
|
||||
- **MonitorModule.vue**([MonitorModule.vue](file:///d:/Atie/Gitea/Thing/src/modules/monitor/MonitorModule.vue)):3 个 Tab(概览/详细/设置),关键指标卡 + SVG sparkline 历史曲线(无外部图表库)+ Accordion 分组 + 断线/降级态提示。
|
||||
|
||||
## 3. 通信协议与数据契约
|
||||
|
||||
### 3.1 路由
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | GET | 健康检查 |
|
||||
| `/status` | GET | 冷启动就绪探测,前端轮询判断是否可订阅 |
|
||||
| `/snapshot` | GET | 一次性拉取最新缓存快照 |
|
||||
| `/stream` | GET (SSE) | 订阅推送,Kernel 按采样间隔主动 Push `SensorSnapshot` |
|
||||
| `/config` | POST | 运行时调整采样间隔(`{"FastIntervalMs":500,"SlowIntervalMs":3000}`) |
|
||||
|
||||
### 3.2 数据契约(显式版本化)
|
||||
|
||||
为避免 Kernel 升级后前端解析炸裂,数据 schema 显式版本化。前端按 `schemaVersion` 解析;Kernel 升级 schema 时递增版本号并提供兼容期。当前 `schemaVersion=1`。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timestamp": 1730000000000,
|
||||
"coldStartMs": 4906,
|
||||
"isAdmin": true,
|
||||
"ready": true,
|
||||
"groups": [
|
||||
{
|
||||
"id": "cpu",
|
||||
"name": "CPU",
|
||||
"sensors": [
|
||||
{ "id": "...", "name": "CPU Package", "type": "temperature", "hardwareName": "Intel Core i5-10400", "value": 52.3, "unit": "°C" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**JSON 字段命名**:双方均 camelCase。C# 端 `JsonSourceGenerationOptions` 设置 `PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase`;Rust 端 `#[serde(rename_all = "camelCase")]`。
|
||||
|
||||
### 3.3 Tauri 命令与事件
|
||||
|
||||
**6 个命令**:
|
||||
|
||||
| 命令 | 作用 |
|
||||
|------|------|
|
||||
| `monitor_kernel_info` | 查询 Kernel 二进制路径/是否存在/端口 |
|
||||
| `monitor_status` | 查询运行状态(running/pid/ready/sensorCount/restartCount) |
|
||||
| `monitor_start` | 启动 Kernel + 自动订阅 SSE(命令和 setup 共用 `start_with_subscription`) |
|
||||
| `monitor_stop` | 停止 Kernel + 取消 SSE 订阅 + 取消自动重连监听 |
|
||||
| `monitor_get_status` | 透传 Kernel `/status` |
|
||||
| `monitor_get_snapshot` | 透传 Kernel `/snapshot` |
|
||||
|
||||
**5 个事件**:`monitor-data`(快照)、`monitor-ready`、`monitor-loading`、`monitor-disconnected`、`monitor-error`。
|
||||
|
||||
## 4. 传感器覆盖度
|
||||
|
||||
在管理员权限下,`--basic` 模式(仅开 CPU/GPU/RAM/Storage)读取两轮后的结果:
|
||||
|
||||
| 硬件分组 | 传感器数 | 覆盖项 |
|
||||
|----------|----------|--------|
|
||||
| CPU | 47 | 每核每线程负载、每核温度、TjMax、时钟、Package/Cores/Memory/Platform 功率、每核电压、Bus Speed |
|
||||
| Memory | 42 | 虚拟/物理内存使用量、可用、负载;两条 DDR4 SPD 时序 + 容量 |
|
||||
| GpuIntel | 17 | GPU 功率、D3D 共享内存总量/已用、D3D 3D/VideoDecode/Copy/VideoProcessing 各引擎负载 |
|
||||
| Storage | 11 | 温度、Power on count/hours、已用/可用/总空间、Read/Write/Total Activity、Read/Write Rate |
|
||||
| **合计** | **117** | 100% 有值 |
|
||||
|
||||
`full` 模式额外多出 Network(60 个传感器)和 Motherboard(本机 0 个,需更深驱动/权限)。
|
||||
|
||||
**非提权降级**:LHB 访问 SMBus、部分 EC 传感器、某些 GPU 传感器需要管理员权限。默认非提权运行,覆盖大部分 CPU/GPU 温度(通过 OHM RPC/WMI 仍可读),牺牲部分主板/电压传感器。提权策略(任务计划程序免 UAC)作为可选优化。
|
||||
|
||||
## 5. 实现要点与经验教训
|
||||
|
||||
### 5.1 C# Kernel(Native AOT)
|
||||
|
||||
**关键配置**([ThingHK.csproj](file:///d:/Atie/Gitea/Thing/ThingHK/ThingHK.csproj)):
|
||||
- `<TargetFramework>net8.0-windows</TargetFramework>`(LHB 在 Windows 下最稳)
|
||||
- `<PublishAot>true</PublishAot>`
|
||||
- `<TrimmerRootDescriptor>rd.xml</TrimmerRootDescriptor>`(LHB 反射根描述,`preserve="all"` 兜底)
|
||||
- JSON 使用 `[JsonSourceGeneration]` 消除 IL3050/IL2026 警告
|
||||
|
||||
**AOT 体积**:18.3 MB(含 ASP.NET Core runtime),无需 .NET Runtime,符合"最小安装"偏好。
|
||||
|
||||
**AOT 踩坑**:
|
||||
1. **匿名类型无法序列化**:`Results.Ok(new { ... })` 在 AOT 下抛 `JsonTypeInfo metadata ... was not provided`。所有响应改用强类型 + `Results.Json(obj, ThingHKJsonContext.Default.T)`,新增 `HealthResponse`/`ErrorResponse` 类型注册到 `[JsonSerializable]`。
|
||||
2. **async lambda 返回类型**:`app.MapPost("/config", async (ctx) => { return Results.Json(...); })` 在 AOT 下返回空响应体。原因:async lambda 返回 `IResult` 被框架当成 `Task<IResult>` 的未等待任务。修复:显式声明 `async Task<IResult> (HttpContext ctx) =>`。
|
||||
3. **HardwareType 枚举变更**:LHB 0.9.5 的 `HardwareType` 无 `Controller`,改为 `SuperIO` + `EmbeddedController`。
|
||||
4. **Computer 非 IDisposable**:LHB 0.9.5 的 `Computer` 类未实现 `IDisposable`,`HardwareManager.Dispose()` 改用显式 `_computer.Close()`。
|
||||
|
||||
### 5.2 Rust 集成
|
||||
|
||||
**关键架构决策**:
|
||||
|
||||
1. **自动启动策略**:参照 proxy 模块的 `auto_start_on_launch` 模式,在 `setup` 中用 `tauri::async_runtime::spawn` 异步拉起 Kernel。硬件监控为被动读取、无副作用(不修改系统状态),故默认启用,无需用户配置开关。
|
||||
2. **`start_with_subscription` 抽取**:将"启动进程 + 注册自动重连 + 启动 SSE 订阅"封装为单一方法,供 `monitor_start` 命令和 setup 自动启动复用,避免两条路径行为漂移。
|
||||
3. **`MonitorKernel` 改为 `Clone` + `Arc<Mutex>` 共享状态**:`tauri::State::inner()` 返回 `&T` 而非 `&Arc<T>`,无法直接 clone 出 `Arc<MonitorKernel>`。将 `sub_handle` 和 `listener_ids` 改为 `Arc<Mutex<...>>`,`MonitorKernel` 派生 `Clone`,clone 出的实例与原实例共享订阅控制状态。
|
||||
4. **`sse_client` 与 `client` 分离**:`client` 带 30s 超时,用于 `/status`、`/snapshot` 等短请求;`sse_client` 无超时,专用于 `/stream` 长连接。早期版本对 SSE 请求误用 `timeout(Duration::from_secs(0))`,导致 reqwest 立即超时失败。
|
||||
5. **写入熔断 + 自动重连**:`subscribe_once` 收到 SSE 断开后停止 `emit`,3 秒退避后重试;Kernel 不响应则退出循环。同时监听 `process-status-changed` 事件,Kernel 由 ProcessManager 自动重启恢复 Running 后主动重新订阅 SSE。
|
||||
|
||||
**Rust 踩坑**:
|
||||
1. **Tauri 2 async 命令必须返回 `Result<T, E>`**:`AsyncCommandMustReturnResult` 未实现 for `MonitorStatus`。修复:`monitor_status` 返回类型从 `MonitorStatus` 改为 `Result<MonitorStatus, String>`。
|
||||
2. **Kernel `/status` 反序列化失败**:C# 默认输出 PascalCase(`Ready`、`IsAdmin`),Rust 期望 camelCase。修复:`Contracts.cs` 的 `JsonSourceGenerationOptions` 添加 `PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase`。
|
||||
3. **崩溃重连无并发订阅问题**:旧 SSE 循环在 `is_kernel_alive` 返回 false(端口未释放)时正确退出,`register_auto_reconnect` 启动新订阅,netstat 确认仅 1 个 ESTABLISHED 连接。
|
||||
|
||||
### 5.3 前端
|
||||
|
||||
**关键设计决策**:
|
||||
|
||||
1. **状态机 `connState`**:基于 status + eventCount + lastEventTime 推断 5 种状态(idle/loading/connected/disconnected/error),5 秒未收到 monitor-data 事件判定为 disconnected。
|
||||
2. **`findSensorValue` 多条件查找**:支持 groupId + name + hardwareName + type 四维匹配,name 用精确匹配 + includes 子串兜底。用于解决 Memory 分组有两条同名 "Memory" 传感器(Virtual/Total)的歧义。
|
||||
3. **SVG sparkline 无外部依赖**:关键指标历史用 30 点环形 buffer,SVG path 自绘,不引入 Chart.js(符合"最小安装、少依赖"偏好)。
|
||||
4. **`schemaVersion` 守卫**:store 订阅 monitor-data 时检查 `schemaVersion !== 1` 并 warn,未来 Kernel 升级 schema 时前端不会静默解析错误数据。
|
||||
5. **模块卸载不停止 Kernel**:`dispose` 仅取消事件订阅,不调用 `monitor_stop`。Kernel 生命周期由 ProcessManager 全局管理,与模块 UI 生命周期解耦。
|
||||
6. **Ready 边沿触发拉取快照**:watch `store.status?.ready` 从 false→true 时主动 `fetchSnapshot()`,避免 Kernel 冷启动就绪后 UI 等待下一个 SSE tick 才有数据。
|
||||
|
||||
**传感器名匹配**(通过 `curl /snapshot` 验证实际传感器名):
|
||||
|
||||
| 指标 | 查找逻辑 | 说明 |
|
||||
|------|----------|------|
|
||||
| CPU 封装温度 | `name: 'CPU Package', type: 'temperature'` | — |
|
||||
| CPU 总负载 | `name: 'CPU Total', type: 'load'` | — |
|
||||
| 内存负载 | `name: 'Memory', hardwareName: 'Total Memory', type: 'load'` | Memory 分组有两条同名传感器(Virtual/Total),需用 hardwareName 消歧 |
|
||||
| Intel GPU 3D 负载 | `name: 'D3D 3D'` ?? `name: 'GPU Core'` ?? `type: 'load'` | LHB 实际输出名是 "D3D 3D",无 "GPU" 前缀 |
|
||||
| 存储温度 | `type: 'temperature'` | 用 type 查找 |
|
||||
|
||||
**前端踩坑**:
|
||||
1. **`lucide-vue-next` 模块未找到**:项目实际使用 `@lucide/vue`(见 package.json),而非 `lucide-vue-next`。
|
||||
2. **`connState === 'idle'` 类型比较错误**:vue-tsc 报 TS2367。v-else-if 已处理 idle 分支,v-else 块内 connState 类型被收窄为非 idle,`=== 'idle'` 永假。修复:移除该比较。
|
||||
|
||||
### 5.4 稳定性与打包
|
||||
|
||||
**冷启动**:约 5 秒(`computer.Open()` 驱动加载占绝大部分,`Update()` 本身约 200ms)。前端 Loading 超时阈值设为 10 秒,轮询间隔 500ms。
|
||||
|
||||
**崩溃恢复验证**:kill ThingHK 进程后,ProcessManager 3s 巡检检测崩溃 → 800ms 端口等待 → spawn 新 Kernel → `register_auto_reconnect` 监听 `process-status-changed(running)` 重新订阅 SSE。旧 SSE 循环正确退出,无并发订阅问题。重启后 ready=True,sensorCount=117(与崩溃前一致)。
|
||||
|
||||
**退出清理验证**:
|
||||
- **正常退出**(quit_app / Ctrl+C):`stop_all()` 同步 kill + 3s 超时 wait,无残留。
|
||||
- **异常崩溃**(kill thing.exe):Windows Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)兜底自动清理,无残留。
|
||||
|
||||
**打包验证**:
|
||||
- `tauri build` 生成 MSI(31 MB)+ NSIS(22.3 MB)安装包。
|
||||
- ThingHK.exe(18.3 MB)通过 [tauri.conf.json](file:///d:/Atie/Gitea/Thing/src-tauri/tauri.conf.json) `"resources": ["binaries/*"]` 打包进安装包。
|
||||
- `BaseDirectory::Resource` 在 release 模式下正确解析到 `<exe_dir>/binaries/ThingHK.exe`(Tauri 2 在打包后指向 exe 所在目录,而非 `resources/` 子目录)。
|
||||
- Release exe 直接运行:Kernel 自动启动成功,ready=True,sensorCount=117。
|
||||
|
||||
**打包踩坑**:`tauri build` 在 TRAE 环境失败 `error: invalid value '1' for '--ci'`。TRAE 环境设置了 `CI=1`,导致 tauri CLI 误解析。修复:构建前清除 `$env:CI = $null`。
|
||||
|
||||
## 6. 文件索引
|
||||
|
||||
### C# Kernel
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| [ThingHK.csproj](file:///d:/Atie/Gitea/Thing/ThingHK/ThingHK.csproj) | .NET 8 AOT 配置,`Microsoft.NET.Sdk.Web` + ASP.NET Core minimal API |
|
||||
| [Program.cs](file:///d:/Atie/Gitea/Thing/ThingHK/Program.cs) | `serve`/`scan` 双子命令(System.CommandLine),默认无参数等价 `serve --port 8730 --basic` |
|
||||
| [Contracts.cs](file:///d:/Atie/Gitea/Thing/ThingHK/Contracts.cs) | 数据契约 + `ThingHKJsonContext`(JSON 源生成,camelCase) |
|
||||
| [HardwareManager.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HardwareManager.cs) | `HardwareManager`(封装 LHB Computer,快慢通道分类)+ `SamplingScheduler`(PeriodicTimer 分频)+ `SnapshotCache`(线程安全缓存)+ `SnapshotVisitor`(递归收集) |
|
||||
| [HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) | 5 个路由 + 全局异常中间件 + `KernelHost`(统一生命周期) |
|
||||
| [rd.xml](file:///d:/Atie/Gitea/Thing/ThingHK/rd.xml) | LHB 反射根描述符,`preserve="all"` 兜底 AOT trimming |
|
||||
|
||||
### Rust 集成
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| [monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) | Tauri 侧 Kernel 客户端:复制二进制、构造 `StartProcessParams`、轮询就绪、SSE 订阅、写入熔断 + 自动重连 |
|
||||
| [lib.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/lib.rs) | 6 个 monitor 命令注册 + setup 自动启动 |
|
||||
| [process_manager.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/process_manager.rs) | 通用进程管理(复用,含崩溃重启 + Job Object) |
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| [monitorStore.ts](file:///d:/Atie/Gitea/Thing/src/stores/monitorStore.ts) | Pinia store:状态管理 + 事件订阅 + `connState` 状态机 + `findSensorValue` |
|
||||
| [MonitorModule.vue](file:///d:/Atie/Gitea/Thing/src/modules/monitor/MonitorModule.vue) | 3 Tab UI(概览/详细/设置)+ 关键指标卡 + SVG sparkline + Accordion 分组 |
|
||||
| [index.ts](file:///d:/Atie/Gitea/Thing/src/modules/monitor/index.ts) | 模块配置和 lifecycle 钩子 |
|
||||
|
||||
### 二进制
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [src-tauri/binaries/ThingHK.exe](file:///d:/Atie/Gitea/Thing/src-tauri/binaries/ThingHK.exe) | Kernel AOT 产物(18.3 MB),随安装包分发,运行时复制到 `{app_data_dir}/monitor/cores/ThingHK.exe` |
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
# 默认 serve(无参数等价)
|
||||
ThingHK.exe serve --port 8730 --basic --fast-ms 1000 --slow-ms 5000
|
||||
|
||||
# 传感器覆盖矩阵测试
|
||||
ThingHK.exe scan --json --basic
|
||||
```
|
||||
|
||||
## 7. 后续可选优化
|
||||
|
||||
以下优化不阻塞当前版本发布,可在后续迭代中考虑:
|
||||
|
||||
1. **ProcessManager Mutex 锁阻塞问题**:`check_and_cleanup` 中 800ms `sleep`(端口等待)在持有 `processes` Mutex 锁的情况下执行,会阻塞所有进程状态查询。建议将重启逻辑移出锁作用域(已知问题,与 proxy 模块共享)。
|
||||
2. **提权策略**:通过 Windows 任务计划程序实现免 UAC 管理员启动,解锁 SMBus/EC/部分 GPU 传感器。当前以普通权限运行,覆盖大部分 CPU/GPU/内存/存储温度。
|
||||
3. **多机型回归**:AMD Ryzen / NVIDIA GPU / 笔记本场景的实际传感器覆盖度验证(需实际硬件)。
|
||||
4. **Kernel 版本管理**:`prepare_kernel` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。
|
||||
@@ -0,0 +1,9 @@
|
||||
<!--
|
||||
反射根描述符:防止 Native AOT 裁剪掉 LibreHardwareMonitorLib 内部通过反射加载的硬件驱动类型。
|
||||
LHB 使用 Activator.CreateInstance + 反射枚举插件类型,未保留会导致运行时 MissingMethodException。
|
||||
这里以宽松模式保留整个 LHB 程序集,确保兼容性;后续若体积过大再收窄。
|
||||
-->
|
||||
<linker>
|
||||
<assembly fullname="LibreHardwareMonitorLib" preserve="all" />
|
||||
<assembly fullname="LibreHardwareMonitorLib.Hardware" preserve="all" />
|
||||
</linker>
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@ use tauri::Manager;
|
||||
mod download_engine;
|
||||
mod logger;
|
||||
mod mihomo_manager;
|
||||
mod monitor_kernel;
|
||||
mod process_manager;
|
||||
|
||||
use download_engine::{
|
||||
@@ -21,6 +22,10 @@ use mihomo_manager::{
|
||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
||||
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager,
|
||||
};
|
||||
use monitor_kernel::{
|
||||
monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_start, monitor_status,
|
||||
monitor_stop, MonitorKernel,
|
||||
};
|
||||
use process_manager::{
|
||||
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
||||
stop_all_processes, stop_process, ProcessManager,
|
||||
@@ -96,6 +101,12 @@ pub fn run() {
|
||||
proxy_set_system_proxy,
|
||||
proxy_clear_system_proxy,
|
||||
proxy_get_system_proxy,
|
||||
monitor_kernel_info,
|
||||
monitor_status,
|
||||
monitor_start,
|
||||
monitor_stop,
|
||||
monitor_get_status,
|
||||
monitor_get_snapshot,
|
||||
downloader_get_tasks,
|
||||
downloader_add_task,
|
||||
downloader_pause_task,
|
||||
@@ -126,6 +137,10 @@ pub fn run() {
|
||||
let mihomo = MihomoManager::new(app_data_dir.clone());
|
||||
app.manage(mihomo);
|
||||
|
||||
// 初始化 MonitorKernel,数据目录: {app_data_dir}/monitor/
|
||||
let monitor = MonitorKernel::new(app_data_dir.clone());
|
||||
app.manage(monitor);
|
||||
|
||||
// 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/
|
||||
let engine = DownloadEngine::new(
|
||||
app_data_dir.join("downloader"),
|
||||
@@ -196,6 +211,18 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 应用启动时自动启动 monitor Kernel(硬件监控默认启用,被动读取无副作用)
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
let monitor = monitor.inner().clone();
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match monitor.start_with_subscription(&app_handle).await {
|
||||
Ok(info) => eprintln!("[monitor] 自动启动成功, pid={:?}", info.pid),
|
||||
Err(e) => eprintln!("[monitor] 自动启动跳过: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
// monitor_kernel.rs — ThingHK 硬件监控内核的 Tauri 侧集成
|
||||
//
|
||||
// 职责(见 ThingHK_GUIDE.md 第 2.1 节 + 阶段三):
|
||||
// 1. 准备 Kernel 可执行文件(从 binaries/ 资源目录复制到工作目录)
|
||||
// 2. 构造 StartProcessParams 交由 ProcessManager 拉起/重启(不自己管生命周期)
|
||||
// 3. 作为 HTTP 客户端:轮询 /status 判断就绪 → 订阅 /stream SSE → emit "monitor-data"
|
||||
// 4. 写入熔断:SSE 断开后停止转发,监听 process-status-changed 在 Kernel 恢复后重新订阅
|
||||
//
|
||||
// 数据流:Kernel --SSE--> MonitorKernel(本文件) --emit--> 前端 MonitorModule.vue
|
||||
//
|
||||
// 注意:Kernel 的 stdout/stderr 被 ProcessManager 设为 null,所有数据交互走 HTTP。
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::process_manager::{ProcessManager, StartProcessParams};
|
||||
|
||||
// ===================== 常量 =====================
|
||||
|
||||
/// Kernel 进程在 ProcessManager 中的 id(与 monitor 模块 index.ts 的 process.name 对应)
|
||||
const PROCESS_ID: &str = "monitor";
|
||||
/// Kernel 监听端口(与 ThingHK 默认端口一致,见 ThingHK Program.cs DefaultPort)
|
||||
const KERNEL_PORT: u16 = 8730;
|
||||
/// 冷启动就绪轮询间隔(与 mihomo 经验一致)
|
||||
const READY_POLL_INTERVAL_MS: u64 = 500;
|
||||
/// 冷启动就绪总超时(阶段一实测冷启动约 5s,留 5s 余量)
|
||||
const READY_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
// ===================== 数据结构 =====================
|
||||
|
||||
/// Kernel /status 响应(与 ThingHK Contracts.cs KernelStatus 对应)
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelStatus {
|
||||
pub ready: bool,
|
||||
pub is_admin: bool,
|
||||
pub uptime_ms: f64,
|
||||
pub group_count: u32,
|
||||
pub sensor_count: u32,
|
||||
pub providers: Vec<String>,
|
||||
pub schema_version: u32,
|
||||
}
|
||||
|
||||
/// Kernel /snapshot 与 /stream 推送的传感器快照(与 ThingHK Contracts.cs SensorSnapshot 对应)
|
||||
/// 这里用 serde_json::Value 透传,避免 Rust 侧重复定义完整 schema:
|
||||
/// Kernel 的 schemaVersion=1 契约由 Kernel 维护,前端按 schemaVersion 解析。
|
||||
pub type SensorSnapshot = serde_json::Value;
|
||||
|
||||
/// 返回给前端的 Kernel 信息
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MonitorKernelInfo {
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// 返回给前端的运行状态
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MonitorStatus {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
pub ready: bool,
|
||||
pub sensor_count: u32,
|
||||
pub restart_count: u32,
|
||||
}
|
||||
|
||||
// ===================== MonitorKernel =====================
|
||||
|
||||
/// Tauri 侧的 Kernel 客户端。
|
||||
/// 持有 HTTP client 和订阅控制句柄,不持有进程句柄(进程由 ProcessManager 管理)。
|
||||
/// 实现 Clone:sub_handle / listener_ids 用 Arc<Mutex> 共享,
|
||||
/// 这样从 tauri::State clone 出的实例与原实例共享订阅控制状态。
|
||||
#[derive(Clone)]
|
||||
pub struct MonitorKernel {
|
||||
root: PathBuf,
|
||||
client: Client,
|
||||
/// 无超时 client,专用于 SSE 长连接(/stream)
|
||||
sse_client: Client,
|
||||
/// SSE 订阅任务句柄,用于在 stop 时取消订阅
|
||||
sub_handle: Arc<Mutex<Option<tauri::async_runtime::JoinHandle<()>>>>,
|
||||
/// 监听 process-status-changed 的句柄,用于在 stop 时取消监听
|
||||
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
|
||||
}
|
||||
|
||||
impl MonitorKernel {
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
let root = app_data_dir.join("monitor");
|
||||
fs::create_dir_all(&root).ok();
|
||||
Self {
|
||||
root,
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
sse_client: Client::builder()
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
sub_handle: Arc::new(Mutex::new(None)),
|
||||
listener_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn cores_dir(&self) -> PathBuf {
|
||||
self.root.join("cores")
|
||||
}
|
||||
pub fn kernel_path(&self) -> PathBuf {
|
||||
self.cores_dir().join("ThingHK.exe")
|
||||
}
|
||||
fn kernel_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", KERNEL_PORT)
|
||||
}
|
||||
|
||||
/// 确保内核就位:若 cores/ 无内核,从资源目录复制
|
||||
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
||||
let kernel = self.kernel_path();
|
||||
if !kernel.exists() {
|
||||
if let Ok(res) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
||||
if res.exists() {
|
||||
fs::create_dir_all(self.cores_dir()).ok();
|
||||
fs::copy(&res, &kernel).map_err(|e| format!("复制 Kernel 失败: {}", e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(MonitorKernelInfo {
|
||||
path: kernel.to_string_lossy().to_string(),
|
||||
exists: kernel.exists(),
|
||||
port: KERNEL_PORT,
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造启动 Kernel 的进程参数(交由 ProcessManager.start 拉起)
|
||||
pub fn prepare_for_start(&self, app: &AppHandle) -> Result<StartProcessParams, String> {
|
||||
let info = self.prepare_kernel(app)?;
|
||||
if !info.exists {
|
||||
return Err(format!(
|
||||
"ThingHK Kernel 未安装。请将 ThingHK.exe 放置到 src-tauri/binaries/ 后重新构建,或直接放到:\n{}",
|
||||
self.cores_dir().to_string_lossy()
|
||||
));
|
||||
}
|
||||
Ok(StartProcessParams {
|
||||
id: PROCESS_ID.into(),
|
||||
executable: self.kernel_path().to_string_lossy().to_string(),
|
||||
args: vec![
|
||||
"serve".into(),
|
||||
"--port".into(),
|
||||
KERNEL_PORT.to_string(),
|
||||
"--basic".into(),
|
||||
],
|
||||
cwd: Some(self.cores_dir().to_string_lossy().to_string()),
|
||||
name: "ThingHK".into(),
|
||||
restart_on_crash: true,
|
||||
max_restarts: 3,
|
||||
})
|
||||
}
|
||||
|
||||
/// 查询 Kernel /status(不启动订阅)
|
||||
pub async fn get_status(&self) -> Result<KernelStatus, String> {
|
||||
let url = format!("{}/status", self.kernel_url());
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 Kernel /status 失败: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Kernel /status 返回 HTTP {}", resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| format!("解析 Kernel /status 失败: {}", e))
|
||||
}
|
||||
|
||||
/// 一次性拉取 /snapshot
|
||||
pub async fn get_snapshot(&self) -> Result<SensorSnapshot, String> {
|
||||
let url = format!("{}/snapshot", self.kernel_url());
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 Kernel /snapshot 失败: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Kernel /snapshot 返回 HTTP {}", resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| format!("解析 Kernel /snapshot 失败: {}", e))
|
||||
}
|
||||
|
||||
/// 启动 SSE 订阅循环。
|
||||
/// 流程:轮询 /status 等 ready → 订阅 /stream → 解析 SSE 事件 → emit "monitor-data"
|
||||
/// 写入熔断:SSE 断开后停止 emit,等待外部调用 reconnect 或 process-status-changed 触发重连
|
||||
pub async fn start_subscription(self: Self, app: AppHandle) {
|
||||
// 1. 轮询等待 Kernel ready(冷启动约 5s)
|
||||
if let Err(e) = self.wait_for_ready(&app).await {
|
||||
eprintln!("[monitor] 等待 Kernel ready 失败,订阅不启动: {}", e);
|
||||
let _ = app.emit("monitor-error", serde_json::json!({ "stage": "ready", "message": e }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 订阅 SSE
|
||||
self.run_sse_loop(app).await;
|
||||
}
|
||||
|
||||
/// 轮询 /status 直到 ready 或超时
|
||||
async fn wait_for_ready(&self, app: &AppHandle) -> Result<(), String> {
|
||||
let url = format!("{}/status", self.kernel_url());
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(READY_TIMEOUT_MS);
|
||||
let mut last_err = String::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
match self
|
||||
.client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(2))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.json::<KernelStatus>().await {
|
||||
Ok(s) if s.ready => {
|
||||
let _ = app.emit(
|
||||
"monitor-ready",
|
||||
serde_json::json!({
|
||||
"isAdmin": s.is_admin,
|
||||
"sensorCount": s.sensor_count,
|
||||
"providers": s.providers,
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => {} // 还没 ready,继续轮询
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
Ok(resp) => last_err = format!("HTTP {}", resp.status()),
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
// 通知前端正在加载(前端可显示 "Kernel 启动中...")
|
||||
let elapsed = READY_TIMEOUT_MS.saturating_sub(deadline.duration_since(std::time::Instant::now()).as_millis() as u64);
|
||||
let _ = app.emit("monitor-loading", serde_json::json!({ "elapsedMs": elapsed }));
|
||||
tokio::time::sleep(Duration::from_millis(READY_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
Err(format!("Kernel 在 {}ms 内未就绪: {}", READY_TIMEOUT_MS, last_err))
|
||||
}
|
||||
|
||||
/// SSE 订阅主循环。
|
||||
/// 断开后自动重试(带退避),实现写入熔断 + 自动重连。
|
||||
async fn run_sse_loop(self: Self, app: AppHandle) {
|
||||
let url = format!("{}/stream", self.kernel_url());
|
||||
loop {
|
||||
match self.subscribe_once(&url, &app).await {
|
||||
// 正常结束(客户端取消或服务端关闭)
|
||||
Ok(()) => {
|
||||
eprintln!("[monitor] SSE 流正常结束");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[monitor] SSE 流异常断开: {},3s 后重试", e);
|
||||
let _ = app.emit(
|
||||
"monitor-disconnected",
|
||||
serde_json::json!({ "message": e }),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
// 重连前先确认 Kernel 是否还活着(可能已被 stop)
|
||||
if !self.is_kernel_alive().await {
|
||||
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅一次 SSE 流,直到断开。
|
||||
/// 解析 `event: snapshot\ndata: {json}\n\n` 格式,emit "monitor-data"。
|
||||
async fn subscribe_once(&self, url: &str, app: &AppHandle) -> Result<(), String> {
|
||||
let resp = self
|
||||
.sse_client
|
||||
.get(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 /stream 失败: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("/stream 返回 HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("读取 SSE chunk 失败: {}", e))?;
|
||||
// SSE 是文本协议,按 UTF-8 解码追加到缓冲区
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// 按双换行分割事件(SSE 事件以空行分隔)
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_str = buffer[..pos].to_string();
|
||||
buffer.drain(..pos + 2);
|
||||
|
||||
if let Some(json_str) = parse_sse_data(&event_str) {
|
||||
if let Ok(snap) = serde_json::from_str::<SensorSnapshot>(&json_str) {
|
||||
let _ = app.emit("monitor-data", snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 Kernel 是否还在响应(用于 SSE 断开后判断是否应重连)
|
||||
async fn is_kernel_alive(&self) -> bool {
|
||||
let url = format!("{}/status", self.kernel_url());
|
||||
self.client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(2))
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 停止 SSE 订阅(进程由 ProcessManager.stop 负责)
|
||||
pub async fn stop_subscription(&self, app: &AppHandle) {
|
||||
// 取消 SSE 任务
|
||||
if let Some(handle) = self.sub_handle.lock().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
// 取消 process-status-changed 监听
|
||||
let ids = self.listener_ids.lock().await.drain(..).collect::<Vec<_>>();
|
||||
for id in ids {
|
||||
app.unlisten(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 process-status-changed 监听:当 Kernel 进程被自动重启恢复 Running 时,
|
||||
/// 自动重新启动 SSE 订阅(实现崩溃恢复后的自愈)。
|
||||
pub async fn register_auto_reconnect(self: Self, app: AppHandle) {
|
||||
let app_clone = app.clone();
|
||||
let this = self.clone();
|
||||
let id = app.listen("process-status-changed", move |event| {
|
||||
// 只关心 monitor 进程的状态变化
|
||||
// ProcessInfo 只有 Serialize,这里用 Value 解析
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(event.payload()) {
|
||||
if v.get("id").and_then(|i| i.as_str()) == Some(PROCESS_ID) {
|
||||
let is_running = v.get("status").and_then(|s| s.as_str()) == Some("running");
|
||||
if is_running {
|
||||
let this = this.clone();
|
||||
let app = app_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
eprintln!("[monitor] 检测到 Kernel 重启恢复,重新订阅 SSE");
|
||||
// 重启后需要重新等待 ready(冷启动约 5s)
|
||||
this.clone().start_subscription(app).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.listener_ids.lock().await.push(id);
|
||||
}
|
||||
|
||||
/// 启动 Kernel 进程并开始 SSE 订阅(命令和 setup 自动启动共用)。
|
||||
/// ProcessManager 通过 app.state 获取,无需外部传入。
|
||||
pub async fn start_with_subscription(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
) -> Result<crate::process_manager::ProcessInfo, String> {
|
||||
let pm = app.state::<ProcessManager>();
|
||||
let params = self.prepare_for_start(app)?;
|
||||
let info = pm.start(params)?;
|
||||
|
||||
// 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅,
|
||||
// 确保 register_auto_reconnect 注册的 listener 与 sub_handle 共享,
|
||||
// stop_subscription 时才能正确清理 listener。
|
||||
let kernel = self.clone();
|
||||
kernel.clone().register_auto_reconnect(app.clone()).await;
|
||||
let app_clone = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 SSE 事件文本,提取 data: 字段的 JSON 内容。
|
||||
/// 格式:`event: snapshot\ndata: {...json...}`
|
||||
fn parse_sse_data(event_str: &str) -> Option<String> {
|
||||
let mut data = String::new();
|
||||
for line in event_str.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
data.push_str(rest.trim());
|
||||
}
|
||||
}
|
||||
if data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Tauri 命令 =====================
|
||||
|
||||
#[tauri::command]
|
||||
pub fn monitor_kernel_info(
|
||||
state: tauri::State<'_, MonitorKernel>,
|
||||
app: AppHandle,
|
||||
) -> Result<MonitorKernelInfo, String> {
|
||||
state.prepare_kernel(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_status(
|
||||
state: tauri::State<'_, MonitorKernel>,
|
||||
pm: tauri::State<'_, ProcessManager>,
|
||||
) -> Result<MonitorStatus, String> {
|
||||
let info = pm.get_status(PROCESS_ID);
|
||||
let running = info
|
||||
.as_ref()
|
||||
.map(|i| matches!(i.status, crate::process_manager::ProcessStatus::Running))
|
||||
.unwrap_or(false);
|
||||
// 只在运行时查询 Kernel /status(避免未运行时发起无意义的 HTTP 请求)
|
||||
let kernel_status = if running { state.get_status().await.ok() } else { None };
|
||||
Ok(MonitorStatus {
|
||||
running,
|
||||
pid: info.as_ref().and_then(|i| i.pid),
|
||||
ready: kernel_status.as_ref().map(|s| s.ready).unwrap_or(false),
|
||||
sensor_count: kernel_status.as_ref().map(|s| s.sensor_count).unwrap_or(0),
|
||||
restart_count: info.as_ref().map(|i| i.restart_count).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_start(
|
||||
state: tauri::State<'_, MonitorKernel>,
|
||||
app: AppHandle,
|
||||
) -> Result<crate::process_manager::ProcessInfo, String> {
|
||||
state.start_with_subscription(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_stop(
|
||||
state: tauri::State<'_, MonitorKernel>,
|
||||
pm: tauri::State<'_, ProcessManager>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
state.stop_subscription(&app).await;
|
||||
pm.stop(PROCESS_ID)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Result<KernelStatus, String> {
|
||||
state.get_status().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
|
||||
state.get_snapshot().await
|
||||
}
|
||||
@@ -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