diff --git a/ThingHK/.gitignore b/ThingHK/.gitignore new file mode 100644 index 0000000..4cd62be --- /dev/null +++ b/ThingHK/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +*.user +publish/ diff --git a/ThingHK/Contracts.cs b/ThingHK/Contracts.cs new file mode 100644 index 0000000..d873240 --- /dev/null +++ b/ThingHK/Contracts.cs @@ -0,0 +1,135 @@ +using System.Text.Json.Serialization; + +namespace ThingHK; + +// ===== 数据契约(与 ThingHK_GUIDE.md 第 2.4 节一致,schemaVersion=1) ===== +// 这些类型同时用于:HTTP 响应、SSE 推送、--scan 命令输出。 +// 任何字段变更必须递增 SensorSnapshot.SchemaVersion。 + +/// +/// 传感器快照:一次全量采样的标准化输出。 +/// 同时用于 GET /snapshot 响应、GET /stream SSE 事件、--scan 命令输出。 +/// +internal sealed class SensorSnapshot +{ + /// 数据契约版本, breaking 变更时递增 + public int SchemaVersion { get; set; } = 1; + + /// 采样时间戳(UTC 毫秒) + public long Timestamp { get; set; } + + /// 从 Kernel 启动到首次产生快照的毫秒数(仅首个快照有意义,后续为 0) + public double ColdStartMs { get; set; } + + /// Kernel 是否以管理员权限运行 + public bool IsAdmin { get; set; } + + /// 当前 Kernel 是否已完成首轮扫描(ready 信号依据) + public bool Ready { get; set; } + + /// 本次快照包含的硬件分组 + public List Groups { get; set; } = new(); +} + +internal sealed class SensorGroup +{ + /// 分组标识(hardware type 小写,如 cpu/gpuintel/storage) + public string Id { get; set; } = ""; + + /// 分组显示名(如 CPU / GpuIntel) + public string Name { get; set; } = ""; + + /// 该分组下的传感器列表 + public List Sensors { get; set; } = new(); +} + +internal sealed class SensorEntry +{ + /// 全局唯一 ID:{groupId}/{hwName}/{sensorType}/{sensorName} 小写化 + public string Id { get; set; } = ""; + + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public string HardwareName { get; set; } = ""; + + /// 传感器当前值;null 表示首轮未就绪或硬件不可读 + public float? Value { get; set; } + + public string Unit { get; set; } = ""; +} + +/// +/// /status 路由响应:用于冷启动就绪探测(见指南第 3.1 节)。 +/// 前端通过轮询此接口判断是否可请求 /snapshot 或订阅 /stream。 +/// +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 Providers { get; set; } = new(); + public int SchemaVersion { get; set; } = 1; +} + +/// +/// /config 请求体:运行时调整采样配置。 +/// 字段均可选,仅传递需要变更的字段。 +/// +internal sealed class ConfigRequest +{ + /// 快通道采样间隔(毫秒),0 或负数表示不变 + public int? FastIntervalMs { get; set; } + + /// 慢通道采样间隔(毫秒),0 或负数表示不变 + public int? SlowIntervalMs { get; set; } + + /// SSE 推送间隔(毫秒),仅影响后续 stream 订阅,0 或负数表示不变 + 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; } +} + +/// +/// 根路由健康检查响应。 +/// +internal sealed class HealthResponse +{ + public bool Ok { get; set; } = true; + public string Name { get; set; } = "ThingHK"; + public string Version { get; set; } = "0.2.0"; +} + +/// +/// 错误响应:AOT 下所有 BadRequest/NotFound 必须用强类型,不能用匿名对象。 +/// +internal sealed class ErrorResponse +{ + public ErrorResponse(string error, string message) { Error = error; Message = message; } + public string Error { get; set; } + public string Message { get; set; } +} + +/// +/// JSON 源生成上下文:消除 Native AOT 下反射式序列化的 IL3050/IL2026 警告。 +/// 所有 HTTP 响应类型必须在此注册,否则 AOT 下序列化会抛异常。 +/// +[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; diff --git a/ThingHK/HardwareManager.cs b/ThingHK/HardwareManager.cs new file mode 100644 index 0000000..96c8233 --- /dev/null +++ b/ThingHK/HardwareManager.cs @@ -0,0 +1,381 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading.Channels; +using LibreHardwareMonitor.Hardware; + +namespace ThingHK; + +/// +/// 硬件访问层:封装 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 设计) +/// +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 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; + } + + /// + /// 全量 Update 所有硬件。 + /// 由 SamplingScheduler 按通道分频调用。 + /// + public void UpdateAll() + { + foreach (var hw in _visitor.AllHardware) + { + try { hw.Update(); } catch { /* 单个硬件 Update 失败不影响整体 */ } + } + } + + /// + /// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。 + /// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateAll。 + /// + public void UpdateSlowOnly() + { + foreach (var hw in _visitor.AllHardware) + { + if (IsSlowHardware(hw.HardwareType)) + { + try { hw.Update(); } catch { /* ignore */ } + } + } + } + + /// + /// 构建 SensorSnapshot:遍历当前所有传感器,不触发 Update。 + /// 调用者应先 Update 再 Snapshot,避免读到旧值。 + /// + 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(); + 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; + } + + /// + /// 判断硬件是否属于慢通道(低频 Update 即可)。 + /// 快通道:CPU/GPU/Memory/Network(变化快、查询轻量) + /// 慢通道:Storage/PSU/Motherboard/Battery/SuperIO/EmbeddedController(查询重或变化慢) + /// + 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 下偶有反射清理异常 */ } + } +} + +/// +/// 遍历 Computer,收集所有 Hardware(含 SubHardware)。 +/// LHB 的 Visit 会递归到 SubHardware。 +/// 引用缓存:visitor 持有 hardware 引用,传感器值通过 hardware.Sensors 实时读取。 +/// +internal sealed class SnapshotVisitor : IVisitor +{ + public List 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) { } +} + +/// +/// 采样调度器:按快/慢通道分频驱动 HardwareManager.UpdateAll。 +/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。 +/// +/// 调度策略: +/// - 快通道 tick:UpdateAll(含慢通道硬件,因 UpdateAll 成本主要在 SMART,已通过慢通道分频减少调用频率) +/// 实际优化:快通道 tick 只 Update 快通道硬件(UpdateFastOnly),慢通道单独按慢节奏 Update +/// - 慢通道 tick:UpdateSlowOnly(仅 Storage/PSU/Motherboard 等) +/// - 每个 tick 结束后构建快照并广播 +/// +internal sealed class SamplingScheduler : IDisposable +{ + private readonly HardwareManager _hw; + private readonly CancellationTokenSource _cts = new(); + private readonly Channel _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(new BoundedChannelOptions(8) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = false, + SingleWriter = true, + }); + _cache = new SnapshotCache(); + } + + /// + /// 启动调度循环。立即推送首个快照(已由 HardwareManager 构造时 Update 过)。 + /// + 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}"); + } + } + } + + /// + /// 订阅快照流。每个 SSE 客户端调用一次。 + /// 返回的 IAsyncEnumerable 会在调度器停止或订阅者取消时结束。 + /// + public IAsyncEnumerable 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(); + } +} + +/// +/// 快照缓存:存储最新快照,供 GET /snapshot 直接返回,避免触发底层重扫描。 +/// 线程安全:读写均加锁,快照对象本身不可变(每次 Update 替换引用)。 +/// +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; } + } +} diff --git a/ThingHK/HttpEndpoints.cs b/ThingHK/HttpEndpoints.cs new file mode 100644 index 0000000..f40c394 --- /dev/null +++ b/ThingHK/HttpEndpoints.cs @@ -0,0 +1,171 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ThingHK; + +/// +/// HTTP 服务层:minimal API 风格扩展方法。 +/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节): +/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅 +/// GET /snapshot → 一次性拉取最新缓存快照 +/// GET /stream → SSE 订阅,Kernel 主动推送 SensorSnapshot +/// POST /config → 运行时调整采样间隔 +/// GET / → 健康检查(200 OK) +/// +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(), + }; + 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 会被框架当成 Task 的未等待任务,导致响应体为空 + app.MapPost("/config", async Task (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; + } + + /// + /// 写一条 SSE 事件:event: snapshot\ndata: {json}\n\n + /// + 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); + } +} + +/// +/// Kernel 宿主:持有 HardwareManager + SamplingScheduler,管理整体生命周期。 +/// Program.cs 构造此对象并注入到 DI 容器供 endpoint 使用。 +/// +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(); + } +} diff --git a/ThingHK/Program.cs b/ThingHK/Program.cs new file mode 100644 index 0000000..4604709 --- /dev/null +++ b/ThingHK/Program.cs @@ -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; + +/// +/// ThingHK 入口。 +/// +/// 子命令: +/// serve (默认) 启动 HTTP 服务,暴露 /status /snapshot /stream /config +/// scan 阶段一的覆盖矩阵测试(保留以便复测) +/// +/// 默认(无参数)等价于 serve --port 8730 --basic。 +/// +internal static class Program +{ + private const int DefaultPort = 8730; + private const int DefaultFastMs = 1000; + private const int DefaultSlowMs = 5000; + + public static async Task 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("--port", () => DefaultPort, "监听端口"); + var basicOpt = new Option("--basic", "仅启用核心四类硬件,降低冷启动耗时"); + var fullOpt = new Option("--full", "启用全部硬件(含主板/控制器/网络等)"); + var fastOpt = new Option("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)"); + var slowOpt = new Option("--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("--json", "JSON 输出"); + var scanBasicOpt = new Option("--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 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; + } + + /// + /// 阶段一覆盖矩阵测试入口(保留)。 + /// + private static async Task 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} 个传感器有值 ===="); + } +} + +/// +/// 把 ASP.NET Core 日志重定向到 stderr,避免污染 stdout(stdout 留给 /snapshot 等 JSON 响应)。 +/// +internal sealed class StderrLoggerProvider : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new StderrLogger(); + public void Dispose() { } + + private sealed class StderrLogger : ILogger + { + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + public void Log(LogLevel level, EventId id, TState state, Exception? ex, Func 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() { } + } +} diff --git a/ThingHK/ThingHK.csproj b/ThingHK/ThingHK.csproj new file mode 100644 index 0000000..ad41190 --- /dev/null +++ b/ThingHK/ThingHK.csproj @@ -0,0 +1,32 @@ + + + + Exe + net8.0-windows + enable + enable + ThingHK + ThingHK + + + true + true + + + rd.xml + + + $(NoWarn);CA1416 + + + 0.2.0 + Thing + ThingHardwareKernel + + + + + + + + diff --git a/ThingHK/ThingHK_GUIDE.md b/ThingHK/ThingHK_GUIDE.md new file mode 100644 index 0000000..785bea0 --- /dev/null +++ b/ThingHK/ThingHK_GUIDE.md @@ -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`(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)): +- `net8.0-windows`(LHB 在 Windows 下最稳) +- `true` +- `rd.xml`(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` 的未等待任务。修复:显式声明 `async Task (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` 共享状态**:`tauri::State::inner()` 返回 `&T` 而非 `&Arc`,无法直接 clone 出 `Arc`。将 `sub_handle` 和 `listener_ids` 改为 `Arc>`,`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`**:`AsyncCommandMustReturnResult` 未实现 for `MonitorStatus`。修复:`monitor_status` 返回类型从 `MonitorStatus` 改为 `Result`。 +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 模式下正确解析到 `/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` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。 diff --git a/ThingHK/rd.xml b/ThingHK/rd.xml new file mode 100644 index 0000000..61a8b58 --- /dev/null +++ b/ThingHK/rd.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/src-tauri/binaries/ThingHK.exe b/src-tauri/binaries/ThingHK.exe new file mode 100644 index 0000000..3b92245 Binary files /dev/null and b/src-tauri/binaries/ThingHK.exe differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f70b98..cba5acc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::() { + 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| { diff --git a/src-tauri/src/monitor_kernel.rs b/src-tauri/src/monitor_kernel.rs new file mode 100644 index 0000000..e614610 --- /dev/null +++ b/src-tauri/src/monitor_kernel.rs @@ -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, + 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, + pub ready: bool, + pub sensor_count: u32, + pub restart_count: u32, +} + +// ===================== MonitorKernel ===================== + +/// Tauri 侧的 Kernel 客户端。 +/// 持有 HTTP client 和订阅控制句柄,不持有进程句柄(进程由 ProcessManager 管理)。 +/// 实现 Clone:sub_handle / listener_ids 用 Arc 共享, +/// 这样从 tauri::State clone 出的实例与原实例共享订阅控制状态。 +#[derive(Clone)] +pub struct MonitorKernel { + root: PathBuf, + client: Client, + /// 无超时 client,专用于 SSE 长连接(/stream) + sse_client: Client, + /// SSE 订阅任务句柄,用于在 stop 时取消订阅 + sub_handle: Arc>>>, + /// 监听 process-status-changed 的句柄,用于在 stop 时取消监听 + listener_ids: Arc>>, +} + +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 { + 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 { + 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 { + 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 { + 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::().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::(&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::>(); + 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::(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 { + let pm = app.state::(); + let params = self.prepare_for_start(app)?; + let info = pm.start(params)?; + + // 用 self 的 clone(共享 Arc 状态)启动订阅, + // 确保 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 { + 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 { + state.prepare_kernel(&app) +} + +#[tauri::command] +pub async fn monitor_status( + state: tauri::State<'_, MonitorKernel>, + pm: tauri::State<'_, ProcessManager>, +) -> Result { + 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 { + 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 { + state.get_status().await +} + +#[tauri::command] +pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result { + state.get_snapshot().await +} diff --git a/src/modules/monitor/MonitorModule.vue b/src/modules/monitor/MonitorModule.vue index d89a1a2..7ff33d6 100644 --- a/src/modules/monitor/MonitorModule.vue +++ b/src/modules/monitor/MonitorModule.vue @@ -1,24 +1,593 @@ \ No newline at end of file + diff --git a/src/modules/monitor/index.ts b/src/modules/monitor/index.ts index 4cc3fcb..28b5330 100644 --- a/src/modules/monitor/index.ts +++ b/src/modules/monitor/index.ts @@ -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 } diff --git a/src/stores/monitorStore.ts b/src/stores/monitorStore.ts new file mode 100644 index 0000000..8eb3587 --- /dev/null +++ b/src/stores/monitorStore.ts @@ -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(null) + const snapshot = ref(null) + const kernelInfo = ref(null) + const errorMsg = ref(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(() => { + 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 = {} + 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('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('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('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('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, + } +})