监控模块 优化

This commit is contained in:
zhongluofeng
2026-07-24 18:16:34 +08:00
parent e8267c244f
commit 7e6149355e
15 changed files with 2148 additions and 234 deletions
+4
View File
@@ -132,4 +132,8 @@ internal sealed class ErrorResponse
[JsonSerializable(typeof(ConfigRequest[]))]
[JsonSerializable(typeof(HealthResponse))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(HardwareConfig))]
[JsonSerializable(typeof(HardwareConfigResponse))]
[JsonSerializable(typeof(HardwareConfigUpdateResponse))]
[JsonSerializable(typeof(HardwareConfigUpdateRequest))]
internal sealed partial class ThingHKJsonContext : JsonSerializerContext;
+165
View File
@@ -0,0 +1,165 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ThingHK;
/// <summary>
/// 硬件监控配置:控制启用哪些硬件分组 + 过滤哪些传感器类型。
///
/// 设计要点:
/// - 硬件开关影响 Computer.IsXxxEnabled,需重启 Kernel 才生效(LHB 在 Open 时决定枚举范围)
/// - 传感器类型过滤在 BuildSnapshot 时生效,可热更新(无需重启)
/// - 默认配置仅覆盖概览页需要的指标:CPU/GPU/Memory/Storage + temp/load/power/clock/data
/// - 配置文件由 Tauri 通过 --config 参数传递路径,Kernel 负责加载/保存
/// </summary>
internal sealed class HardwareConfig
{
/// <summary>硬件分组开关,key 为 HardwareConfigKeycpu/gpu/memory/storage/...</summary>
public Dictionary<string, bool> Hardware { get; set; } = new();
/// <summary>传感器类型开关,key 为 SensorType 小写(temperature/load/power/...</summary>
public Dictionary<string, bool> SensorTypes { get; set; } = new();
/// <summary>默认配置:仅覆盖概览页需要的指标,最小化性能开销</summary>
public static HardwareConfig Default => new()
{
Hardware = new Dictionary<string, bool>
{
["cpu"] = true,
["gpu"] = true,
["memory"] = true,
["storage"] = true,
["motherboard"] = false,
["controller"] = false,
["battery"] = false,
["network"] = false,
["psu"] = false,
},
SensorTypes = new Dictionary<string, bool>
{
["temperature"] = true,
["load"] = true,
["power"] = true,
["clock"] = true,
["voltage"] = false,
["fan"] = false,
["data"] = true,
["smalldata"] = false,
["throughput"] = false,
["level"] = false,
["control"] = false,
["factor"] = false,
["frequency"] = false,
["timespan"] = false,
["energy"] = false,
["noise"] = false,
["conductivity"] = false,
["humidity"] = false,
["flow"] = false,
},
};
/// <summary>从文件加载配置,文件不存在或解析失败时返回默认配置</summary>
public static HardwareConfig Load(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
return Default;
try
{
var json = File.ReadAllText(path);
var cfg = JsonSerializer.Deserialize(json, ThingHKJsonContext.Default.HardwareConfig);
if (cfg == null) return Default;
// 合并默认值:确保新增的硬件/传感器类型有默认值
MergeWithDefaults(cfg);
return cfg;
}
catch
{
return Default;
}
}
/// <summary>保存配置到文件</summary>
public void Save(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(this, ThingHKJsonContext.Default.HardwareConfig);
File.WriteAllText(path, json);
}
/// <summary>检查指定传感器类型是否启用</summary>
public bool IsSensorTypeEnabled(string sensorTypeLower)
=> SensorTypes.TryGetValue(sensorTypeLower, out var enabled) ? enabled : false;
/// <summary>检查指定硬件分组是否启用</summary>
public bool IsHardwareEnabled(string key)
=> Hardware.TryGetValue(key, out var enabled) ? enabled : false;
/// <summary>合并默认值:确保配置文件缺少的新增项有默认值</summary>
private static void MergeWithDefaults(HardwareConfig cfg)
{
var def = Default;
foreach (var (k, v) in def.Hardware)
if (!cfg.Hardware.ContainsKey(k)) cfg.Hardware[k] = v;
foreach (var (k, v) in def.SensorTypes)
if (!cfg.SensorTypes.ContainsKey(k)) cfg.SensorTypes[k] = v;
}
}
/// <summary>
/// /config/hardware GET 响应:返回当前配置 + 可用硬件/传感器类型清单。
/// 前端用此数据渲染配置 Dialog。
/// </summary>
internal sealed class HardwareConfigResponse
{
public HardwareConfig Config { get; set; } = new();
public List<HardwareTypeInfo> AvailableHardware { get; set; } = new();
public List<SensorTypeInfo> AvailableSensorTypes { get; set; } = new();
}
/// <summary>硬件分组元信息</summary>
internal sealed class HardwareTypeInfo
{
/// <summary>配置 keycpu/gpu/memory/...</summary>
public string Key { get; set; } = "";
/// <summary>显示名</summary>
public string Name { get; set; } = "";
/// <summary>是否已启用</summary>
public bool Enabled { get; set; }
/// <summary>是否需要管理员权限才能读取完整数据</summary>
public bool RequiresAdmin { get; set; }
}
/// <summary>传感器类型元信息</summary>
internal sealed class SensorTypeInfo
{
/// <summary>配置 keytemperature/load/...</summary>
public string Key { get; set; } = "";
/// <summary>显示名</summary>
public string Name { get; set; } = "";
/// <summary>是否已启用</summary>
public bool Enabled { get; set; }
}
/// <summary>/config/hardware POST 响应</summary>
internal sealed class HardwareConfigUpdateResponse
{
public bool Success { get; set; }
/// <summary>硬件开关是否变化(需重启 Kernel 才生效)</summary>
public bool RestartRequired { get; set; }
public string? ConfigPath { get; set; }
}
/// <summary>/config/hardware POST 请求体</summary>
internal sealed class HardwareConfigUpdateRequest
{
/// <summary>硬件开关(可选,仅传需要变更的字段)</summary>
public Dictionary<string, bool>? Hardware { get; set; }
/// <summary>传感器类型开关(可选,仅传需要变更的字段)</summary>
public Dictionary<string, bool>? SensorTypes { get; set; }
}
+31 -12
View File
@@ -26,6 +26,7 @@ internal sealed class HardwareManager : IDisposable
private readonly Stopwatch _startupSw;
private bool _ready;
private bool _closed;
private HardwareConfig _config;
// 缓存"哪个硬件属于快通道"——按 hardware type 判断更稳定(不同机型传感器命名不一致)
// 实际分频逻辑见 SamplingScheduler,此处只暴露硬件列表给调度器
@@ -34,24 +35,31 @@ internal sealed class HardwareManager : IDisposable
public bool IsAdmin => _isAdmin;
public double ColdStartMs => _coldStartMs;
public bool Ready => _ready;
public HardwareConfig Config => _config;
public HardwareManager(bool basic)
public HardwareManager(HardwareConfig config)
{
_config = config ?? HardwareConfig.Default;
_isAdmin = IsRunningAsAdmin();
_startupSw = Stopwatch.StartNew();
// LHB 行为:主板的传感器实际通过 SubHardwareSuperIO/EC)暴露,
// 而 SuperIO 作为顶层硬件时需 IsControllerEnabled 才能枚举。
// 联动策略:启用 motherboard 时自动启用 controller,避免主板勾选后无数据。
bool motherboardEnabled = _config.IsHardwareEnabled("motherboard");
bool controllerEnabled = _config.IsHardwareEnabled("controller") || motherboardEnabled;
_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,
IsCpuEnabled = _config.IsHardwareEnabled("cpu"),
IsGpuEnabled = _config.IsHardwareEnabled("gpu"),
IsMemoryEnabled = _config.IsHardwareEnabled("memory"),
IsStorageEnabled = _config.IsHardwareEnabled("storage"),
IsMotherboardEnabled = motherboardEnabled,
IsControllerEnabled = controllerEnabled,
IsBatteryEnabled = _config.IsHardwareEnabled("battery"),
IsNetworkEnabled = _config.IsHardwareEnabled("network"),
IsPsuEnabled = _config.IsHardwareEnabled("psu"),
};
_computer.Open();
@@ -68,6 +76,12 @@ internal sealed class HardwareManager : IDisposable
_ready = true;
}
/// <summary>热更新传感器类型过滤(无需重启 Kernel,下次 BuildSnapshot 生效)</summary>
public void UpdateConfig(HardwareConfig newConfig)
{
_config = newConfig;
}
/// <summary>
/// 全量 Update 所有硬件。
/// 由 SamplingScheduler 按通道分频调用。
@@ -130,11 +144,16 @@ internal sealed class HardwareManager : IDisposable
foreach (var s in hw.Sensors)
{
// 按配置过滤传感器类型:关闭的类型不加入快照,减少传输数据量
string sensorType = s.SensorType.ToString().ToLowerInvariant();
if (!_config.IsSensorTypeEnabled(sensorType))
continue;
g.Sensors.Add(new SensorEntry
{
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
Name = s.Name,
Type = s.SensorType.ToString().ToLowerInvariant(),
Type = sensorType,
Value = s.Value,
Unit = UnitFor(s.SensorType),
HardwareName = hw.Name,
+190 -4
View File
@@ -10,13 +10,14 @@ namespace ThingHK;
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
/// GET /snapshot → 一次性拉取最新缓存快照
/// GET /stream → SSE 阅,Kernel 主动推送 SensorSnapshot
/// GET /stream → SSE 阅,Kernel 主动推送 SensorSnapshot
/// POST /config → 运行时调整采样间隔
/// POST /shutdown → 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程)
/// GET / → 健康检查(200 OK)
/// </summary>
internal static class HttpEndpoints
{
public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel)
public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel, CancellationTokenSource shutdownCts)
{
// 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错
app.Use(async (ctx, next) =>
@@ -122,6 +123,116 @@ internal static class HttpEndpoints
}, ThingHKJsonContext.Default.ConfigResponse);
});
// 硬件监控配置:返回当前配置 + 可用硬件/传感器类型清单
app.MapGet("/config/hardware", () =>
{
var hw = kernel.Hardware;
var cfg = hw?.Config ?? HardwareConfig.Default;
var resp = new HardwareConfigResponse
{
Config = cfg,
AvailableHardware = GetAvailableHardware(cfg),
AvailableSensorTypes = GetAvailableSensorTypes(cfg),
};
return Results.Json(resp, ThingHKJsonContext.Default.HardwareConfigResponse);
});
// 硬件监控配置更新:
// - 传感器类型过滤:热更新(下次 BuildSnapshot 生效)
// - 硬件开关:需重启 Kernel 才生效(返回 restartRequired=true
app.MapPost("/config/hardware", async Task<IResult> (HttpContext ctx) =>
{
using var reader = new StreamReader(ctx.Request.Body);
var body = await reader.ReadToEndAsync(ctx.RequestAborted);
HardwareConfigUpdateRequest? req;
try
{
req = string.IsNullOrWhiteSpace(body)
? new HardwareConfigUpdateRequest()
: JsonSerializer.Deserialize(body, ThingHKJsonContext.Default.HardwareConfigUpdateRequest);
}
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);
}
var hw = kernel.Hardware;
if (hw == null)
{
ctx.Response.StatusCode = 503;
return Results.Json(new ErrorResponse("not_ready", "Kernel 尚未就绪"), ThingHKJsonContext.Default.ErrorResponse);
}
var oldConfig = hw.Config;
var newConfig = new HardwareConfig
{
Hardware = new Dictionary<string, bool>(oldConfig.Hardware),
SensorTypes = new Dictionary<string, bool>(oldConfig.SensorTypes),
};
bool hardwareChanged = false;
if (req.Hardware != null)
{
foreach (var (k, v) in req.Hardware)
{
if (newConfig.Hardware.TryGetValue(k, out var oldVal) && oldVal != v)
{
newConfig.Hardware[k] = v;
hardwareChanged = true;
}
else if (!newConfig.Hardware.ContainsKey(k))
{
newConfig.Hardware[k] = v;
hardwareChanged = true;
}
}
}
if (req.SensorTypes != null)
{
foreach (var (k, v) in req.SensorTypes)
newConfig.SensorTypes[k] = v;
}
// 热更新传感器类型过滤(立即生效)
hw.UpdateConfig(newConfig);
// 保存到文件(供下次启动加载)
if (!string.IsNullOrEmpty(kernel.ConfigPath))
{
try { newConfig.Save(kernel.ConfigPath); }
catch (Exception ex) { Console.Error.WriteLine($"[ThingHK] 保存配置失败: {ex.Message}"); }
}
return Results.Json(new HardwareConfigUpdateResponse
{
Success = true,
RestartRequired = hardwareChanged,
ConfigPath = kernel.ConfigPath,
}, ThingHKJsonContext.Default.HardwareConfigUpdateResponse);
});
// 优雅关闭:触发 CancellationTokenSource.Cancel,让 app.RunAsync 退出。
// 提权模式下 Tauri(普通权限)无法 TerminateProcess 管理员进程,通过此接口让 Kernel 自行退出。
// 普通权限模式下 ProcessManager.stop 直接 kill 更快,此接口作为统一兜底。
app.MapPost("/shutdown", () =>
{
Console.Error.WriteLine("[ThingHK] 收到 /shutdown 请求,准备退出");
// 延迟取消,确保响应先返回
_ = Task.Run(async () =>
{
await Task.Delay(100);
shutdownCts.Cancel();
});
return Results.Json(new HealthResponse { Ok = true, Name = "ThingHK", Version = "0.2.0" }, ThingHKJsonContext.Default.HealthResponse);
});
return app;
}
@@ -138,6 +249,75 @@ internal static class HttpEndpoints
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
await ctx.Response.Body.FlushAsync(ct).ConfigureAwait(false);
}
/// <summary>可用硬件分组清单(固定列表,供前端 Dialog 渲染)</summary>
private static List<HardwareTypeInfo> GetAvailableHardware(HardwareConfig cfg)
{
// (key, displayName, requiresAdmin)
var items = new (string, string, bool)[]
{
("cpu", "CPU", false),
("gpu", "GPUIntel/AMD/NVIDIA", false),
("memory", "内存", false),
("storage", "存储", true),
("motherboard", "主板", true),
("controller", "SuperIO/嵌入式控制器", true),
("battery", "电池", false),
("network", "网络", false),
("psu", "电源", true),
};
var list = new List<HardwareTypeInfo>(items.Length);
foreach (var (key, name, reqAdmin) in items)
{
list.Add(new HardwareTypeInfo
{
Key = key,
Name = name,
Enabled = cfg.IsHardwareEnabled(key),
RequiresAdmin = reqAdmin,
});
}
return list;
}
/// <summary>可用传感器类型清单(固定列表,供前端 Dialog 渲染)</summary>
private static List<SensorTypeInfo> GetAvailableSensorTypes(HardwareConfig cfg)
{
// (key, displayName)
var items = new (string, string)[]
{
("temperature", "温度"),
("load", "负载"),
("power", "功率"),
("clock", "时钟"),
("voltage", "电压"),
("fan", "风扇"),
("data", "容量"),
("smalldata", "小容量"),
("throughput", "吞吐"),
("level", "等级"),
("control", "控制"),
("factor", "因子"),
("frequency", "频率"),
("timespan", "时长"),
("energy", "能量"),
("noise", "噪声"),
("conductivity", "电导率"),
("humidity", "湿度"),
("flow", "流量"),
};
var list = new List<SensorTypeInfo>(items.Length);
foreach (var (key, name) in items)
{
list.Add(new SensorTypeInfo
{
Key = key,
Name = name,
Enabled = cfg.IsSensorTypeEnabled(key),
});
}
return list;
}
}
/// <summary>
@@ -149,11 +329,17 @@ internal sealed class KernelHost : IDisposable
public HardwareManager? Hardware { get; private set; }
public SamplingScheduler Scheduler { get; private set; } = null!;
public Stopwatch Uptime { get; } = Stopwatch.StartNew();
/// <summary>配置文件路径(供 /config/hardware POST 保存)</summary>
public string? ConfigPath { get; private set; }
public async Task StartAsync(bool basic, int fastMs, int slowMs)
public async Task StartAsync(string? configPath, int fastMs, int slowMs)
{
ConfigPath = configPath;
var config = HardwareConfig.Load(configPath);
Console.Error.WriteLine($"[ThingHK] 加载配置: {configPath ?? "()"}, 硬件={config.Hardware.Count}项 传感器类型={config.SensorTypes.Count}项");
// 1. 初始化硬件层(含首轮 Update,约 5 秒)
Hardware = new HardwareManager(basic);
Hardware = new HardwareManager(config);
Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}");
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
+48 -16
View File
@@ -27,24 +27,24 @@ internal static class Program
// 无参数 → 默认 serve(最常见用法)
if (args.Length == 0)
{
return await ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs);
return await ServeAsync(DefaultPort, null, DefaultFastMs, DefaultSlowMs, null);
}
// 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 configOpt = new Option<FileInfo?>("--config", "硬件监控配置文件路径(JSON");
var fastOpt = new Option<int>("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)");
var slowOpt = new Option<int>("--slow-ms", () => DefaultSlowMs, "慢通道采样间隔(毫秒)");
var logFileOpt = new Option<FileInfo?>("--log-file", "将 stderr 日志写入指定文件(提权模式下控制台隐藏时使用)");
serveCmd.AddOption(portOpt);
serveCmd.AddOption(basicOpt);
serveCmd.AddOption(fullOpt);
serveCmd.AddOption(configOpt);
serveCmd.AddOption(fastOpt);
serveCmd.AddOption(slowOpt);
serveCmd.SetHandler((port, basic, full, fast, slow) =>
ServeAsync(port, basic || !full, fast, slow),
portOpt, basicOpt, fullOpt, fastOpt, slowOpt);
serveCmd.AddOption(logFileOpt);
serveCmd.SetHandler((port, config, fast, slow, logFile) =>
ServeAsync(port, config?.FullName, fast, slow, logFile),
portOpt, configOpt, fastOpt, slowOpt, logFileOpt);
// scan 子命令(阶段一覆盖矩阵测试,保留)
var scanCmd = new Command("scan", "扫描一次并输出覆盖矩阵(阶段一验证用)");
@@ -58,17 +58,37 @@ internal static class Program
root.Add(serveCmd);
root.Add(scanCmd);
// 兼容阶段一的旧用法:ThingHK.exe --json --basic
root.SetHandler(() => ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs));
root.SetHandler(() => ServeAsync(DefaultPort, null, DefaultFastMs, DefaultSlowMs, null));
return await root.InvokeAsync(args);
}
private static async Task<int> ServeAsync(int port, bool basic, int fastMs, int slowMs)
private static async Task<int> ServeAsync(int port, string? configPath, int fastMs, int slowMs, FileInfo? logFile)
{
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} basic={basic} fast={fastMs}ms slow={slowMs}ms");
// 如果指定了 --log-file,将 stderr 重定向到文件(提权模式下控制台隐藏,需要日志排查崩溃)
if (logFile != null)
{
try
{
var logStream = new StreamWriter(logFile.Open(FileMode.Create, FileAccess.Write, FileShare.Read))
{
AutoFlush = true,
};
Console.SetError(logStream);
// 同时重定向 stdout(避免 ASP.NET 的某些输出到 stdout
Console.SetOut(logStream);
Console.Error.WriteLine($"[ThingHK] 日志输出到: {logFile.FullName}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ThingHK] 无法写入日志文件 {logFile.FullName}: {ex.Message}");
}
}
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "()"} fast={fastMs}ms slow={slowMs}ms");
using var kernel = new KernelHost();
await kernel.StartAsync(basic, fastMs, slowMs);
await kernel.StartAsync(configPath, fastMs, slowMs);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}/");
@@ -78,15 +98,16 @@ internal static class Program
builder.Logging.AddProvider(new StderrLoggerProvider());
var app = builder.Build();
app.MapThingHKEndpoints(kernel);
// Ctrl+C 优雅关闭
// Ctrl+C 优雅关闭cts 同时用于 /shutdown 接口触发退出)
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel();
app.MapThingHKEndpoints(kernel, cts);
Console.Error.WriteLine($"[ThingHK] HTTP 服务监听: http://127.0.0.1:{port}/");
Console.Error.WriteLine("[ThingHK] 路由: GET / GET /status GET /snapshot GET /stream POST /config");
Console.Error.WriteLine("[ThingHK] 路由: GET / GET /status GET /snapshot GET /stream POST /config POST /shutdown");
try
{
@@ -103,7 +124,18 @@ internal static class Program
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);
// scan 命令保留旧 basic 参数:basic=true 用默认配置,basic=false 用全部启用配置
var config = basic ? HardwareConfig.Default : new HardwareConfig
{
Hardware = new Dictionary<string, bool>
{
["cpu"] = true, ["gpu"] = true, ["memory"] = true, ["storage"] = true,
["motherboard"] = true, ["controller"] = true, ["battery"] = true,
["network"] = true, ["psu"] = true,
},
SensorTypes = HardwareConfig.Default.SensorTypes,
};
using var hw = new HardwareManager(config);
// 第二轮 Update:部分传感器首轮才初始化
hw.UpdateAll();
+42 -9
View File
@@ -57,6 +57,7 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程
| `/snapshot` | GET | 一次性拉取最新缓存快照 |
| `/stream` | GET (SSE) | 订阅推送,Kernel 按采样间隔主动 Push `SensorSnapshot` |
| `/config` | POST | 运行时调整采样间隔(`{"FastIntervalMs":500,"SlowIntervalMs":3000}` |
| `/shutdown` | POST | 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程) |
### 3.2 数据契约(显式版本化)
@@ -85,14 +86,15 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程
### 3.3 Tauri 命令与事件
**6 个命令**
**7 个命令**
| 命令 | 作用 |
|------|------|
| `monitor_kernel_info` | 查询 Kernel 二进制路径/是否存在/端口 |
| `monitor_status` | 查询运行状态(running/pid/ready/sensorCount/restartCount |
| `monitor_status` | 查询运行状态(running/pid/ready/sensorCount/restartCount/elevated |
| `monitor_start` | 启动 Kernel + 自动订阅 SSE(命令和 setup 共用 `start_with_subscription` |
| `monitor_stop` | 停止 Kernel + 取消 SSE 订阅 + 取消自动重连监听 |
| `monitor_start_elevated` | 以管理员权限启动 Kernel(弹 UAC),停止走 `/shutdown` 接口 |
| `monitor_stop` | 停止 Kernel(提权模式走 `/shutdown`,普通模式走 ProcessManager |
| `monitor_get_status` | 透传 Kernel `/status` |
| `monitor_get_snapshot` | 透传 Kernel `/snapshot` |
@@ -200,15 +202,15 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程
| [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`(统一生命周期) |
| [HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) | 6 个路由(含 `/shutdown` 优雅关闭)+ 全局异常中间件 + `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 自动启动 |
| [monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) | Tauri 侧 Kernel 客户端:复制二进制、构造 `StartProcessParams`、轮询就绪、SSE 订阅、写入熔断 + 自动重连、ShellExecuteW 提权启动 |
| [lib.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/lib.rs) | 7 个 monitor 命令注册 + setup 自动启动 |
| [process_manager.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/process_manager.rs) | 通用进程管理(复用,含崩溃重启 + Job Object |
### 前端
@@ -235,11 +237,42 @@ ThingHK.exe serve --port 8730 --basic --fast-ms 1000 --slow-ms 5000
ThingHK.exe scan --json --basic
```
## 7. 后续可选优化
## 7. 提权机制
以下优化不阻塞当前版本发布,可在后续迭代中考虑:
### 7.1 背景
LibreHardwareMonitor 访问 CPU MSR(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。普通权限下可读传感器受限:
| 权限 | 分组 | 传感器数 | 可读项 |
|------|------|----------|--------|
| 普通 | cpu/memory/gpuintel | 62 | CPU 负载/功率、内存、GPU 负载(温度/时钟全 null) |
| 管理员 | cpu/memory/gpuintel/storage | 117 | 完整温度/时钟/电压/存储 SMART |
### 7.2 实现方案
采用 **ShellExecuteW "runas"** 弹 UAC 提权启动 Kernel,架构上与普通模式分离:
- **启动**[monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) `start_elevated` → 停止当前 Kernel → `ShellExecuteW "runas"` 启动管理员 Kernel(隐藏控制台窗口)→ 等待 ready → 重新订阅 SSE
- **停止**:调用 Kernel 的 `POST /shutdown` 接口(普通权限无法 `TerminateProcess` 管理员进程)
- **状态查询**:通过 HTTP `/status` 判断运行状态(pid 不可用,返回 None)
### 7.3 提权模式限制
| 能力 | 普通模式 | 提权模式 |
|------|----------|----------|
| 进程管理 | ProcessManagerkill/restart/Job Object | 不受管控(跨权限级别句柄不可用) |
| 崩溃重启 | 3s 巡检 + 自动重启 | 不支持(需手动重新提权) |
| 停止方式 | `ProcessManager.stop`kill | `POST /shutdown`(优雅退出) |
| PID 查询 | 可用 | 不可用(返回 None) |
| UAC 弹窗 | 无 | 每次启动需确认 |
### 7.4 C# Kernel /shutdown 接口
[HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) 增加 `POST /shutdown` 路由,触发 `CancellationTokenSource.Cancel``app.RunAsync` 优雅退出。响应先返回 200,延迟 100ms 后取消,确保响应体完整发送。
## 8. 后续可选优化
1. **ProcessManager Mutex 锁阻塞问题**`check_and_cleanup` 中 800ms `sleep`(端口等待)在持有 `processes` Mutex 锁的情况下执行,会阻塞所有进程状态查询。建议将重启逻辑移出锁作用域(已知问题,与 proxy 模块共享)。
2. **提权策略**:通过 Windows 任务计划程序实现免 UAC 管理员启动,解锁 SMBus/EC/部分 GPU 传感器。当前以普通权限运行,覆盖大部分 CPU/GPU/内存/存储温度
2. **免 UAC 提权**:当前每次提权需弹 UAC,可通过 Windows 任务计划程序注册"以最高权限运行"的任务实现免 UAC 启动(需一次性管理员权限注册任务)
3. **多机型回归**AMD Ryzen / NVIDIA GPU / 笔记本场景的实际传感器覆盖度验证(需实际硬件)。
4. **Kernel 版本管理**`prepare_kernel` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。
+78
View File
@@ -0,0 +1,78 @@
<#
.SYNOPSIS
ThingHK Native AOT build script.
.DESCRIPTION
Builds ThingHK as Native AOT single-file executable and copies it to src-tauri/binaries/ for Tauri packaging.
Usage: run .\build.ps1 in the ThingHK/ directory.
#>
[CmdletBinding()]
param(
[string]$Configuration = "Release",
[string]$Runtime = "win-x64"
)
$ErrorActionPreference = "Stop"
# Resolve script directory: try multiple methods for compatibility
$scriptDir = $PSScriptRoot
if (-not $scriptDir) {
$cmd = $MyInvocation.MyCommand
if ($cmd -and $cmd.Path) {
$scriptDir = Split-Path -Parent $cmd.Path
}
}
if (-not $scriptDir) {
$scriptDir = (Get-Location).Path
}
$projectPath = Join-Path $scriptDir "ThingHK.csproj"
$publishDir = Join-Path $scriptDir "bin\$Configuration\net8.0-windows\$Runtime\publish"
$destDir = Join-Path $scriptDir "..\src-tauri\binaries"
$destPath = Join-Path $destDir "ThingHK.exe"
Write-Host "=== ThingHK Build Started ===" -ForegroundColor Cyan
Write-Host "Config: $Configuration | Runtime: $Runtime"
Write-Host "Project: $projectPath"
Write-Host "ScriptDir: $scriptDir"
if (-not (Test-Path $projectPath)) {
Write-Error "Project file not found: $projectPath"
exit 1
}
# 1. Clean old obj cache
$objDir = Join-Path $scriptDir "obj"
if (Test-Path $objDir) {
Write-Host "`n[1/4] Cleaning obj cache..." -ForegroundColor Yellow
Remove-Item $objDir -Recurse -Force -ErrorAction SilentlyContinue
}
# 2. dotnet publish (Native AOT)
Write-Host "`n[2/4] Running dotnet publish (Native AOT)..." -ForegroundColor Yellow
& dotnet publish $projectPath -c $Configuration -r $Runtime
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed (exit $LASTEXITCODE)"
exit $LASTEXITCODE
}
$publishedExe = Join-Path $publishDir "ThingHK.exe"
if (-not (Test-Path $publishedExe)) {
Write-Error "Build output not found: $publishedExe"
exit 1
}
# 3. Ensure destination directory exists
if (-not (Test-Path $destDir)) {
Write-Host "`n[3/4] Creating destination: $destDir" -ForegroundColor Yellow
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
}
# 4. Copy to src-tauri/binaries/
Write-Host "`n[4/4] Copying to $destPath" -ForegroundColor Yellow
Copy-Item $publishedExe $destPath -Force
$destItem = Get-Item $destPath
Write-Host "`n=== Build Complete ===" -ForegroundColor Green
Write-Host ("Output: {0}" -f $destItem.FullName)
Write-Host ("Size: {0:N2} MB" -f ($destItem.Length / 1MB))
Write-Host ("Time: {0}" -f $destItem.LastWriteTime)
+136 -14
View File
@@ -711,6 +711,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
@@ -1043,6 +1062,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "embed-resource"
version = "3.0.11"
@@ -2394,6 +2419,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -3021,6 +3055,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3824,6 +3878,20 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "sysinfo"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"rayon",
"windows 0.57.0",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
@@ -3892,7 +3960,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3963,7 +4031,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4118,7 +4186,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows",
"windows 0.61.3",
"zbus",
]
@@ -4160,7 +4228,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4185,7 +4253,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4272,6 +4340,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -5023,10 +5092,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@@ -5047,7 +5116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5097,6 +5166,16 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -5119,14 +5198,26 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -5138,8 +5229,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -5156,6 +5247,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -5167,6 +5269,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -5211,6 +5324,15 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5665,7 +5787,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+2 -1
View File
@@ -33,10 +33,11 @@ axum = "0.7"
url = "2"
zip = "2"
dirs = "5"
sysinfo = "0.32"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
windows-sys = { version = "0.52", features = ["Win32_Networking_WinInet", "Win32_Foundation"] }
windows-sys = { version = "0.52", features = ["Win32_Networking_WinInet", "Win32_Foundation", "Win32_Security", "Win32_System_Threading"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2"
Binary file not shown.
+34 -1
View File
@@ -1,9 +1,11 @@
use std::sync::Arc;
use tauri::Manager;
mod download_engine;
mod logger;
mod mihomo_manager;
mod monitor_kernel;
mod network_monitor;
mod process_manager;
use download_engine::{
@@ -23,9 +25,12 @@ use mihomo_manager::{
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_elevate_self, monitor_get_elevate_on_launch, monitor_get_hardware_config,
monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_set_elevate_on_launch,
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status,
monitor_stop, MonitorKernel,
};
use network_monitor::{network_monitor_status, NetworkMonitor};
use process_manager::{
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
stop_all_processes, stop_process, ProcessManager,
@@ -41,12 +46,15 @@ fn quit_app(
state: tauri::State<'_, ProcessManager>,
mihomo: tauri::State<'_, MihomoManager>,
engine: tauri::State<'_, DownloadEngine>,
monitor: tauri::State<'_, MonitorKernel>,
app: tauri::AppHandle,
) {
// 退出前清理系统代理,避免遗留导致网络问题
mihomo.cleanup_on_exit();
// 退出前保存下载引擎状态
engine.cleanup_on_exit();
// 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown
tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app));
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
state.stop_all();
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
@@ -104,9 +112,16 @@ pub fn run() {
monitor_kernel_info,
monitor_status,
monitor_start,
monitor_start_elevated,
monitor_elevate_self,
monitor_stop,
monitor_get_status,
monitor_get_snapshot,
monitor_get_elevate_on_launch,
monitor_set_elevate_on_launch,
monitor_get_hardware_config,
monitor_set_hardware_config,
network_monitor_status,
downloader_get_tasks,
downloader_add_task,
downloader_pause_task,
@@ -134,6 +149,12 @@ pub fn run() {
.app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
// 永久提权检查:如果标志已设置且当前非管理员,以管理员权限重启自身并退出
// 必须在所有模块初始化之前执行(此时无资源需要清理)
if monitor_kernel::check_and_relaunch_if_needed(&app_data_dir) {
std::process::exit(0);
}
let mihomo = MihomoManager::new(app_data_dir.clone());
app.manage(mihomo);
@@ -141,6 +162,12 @@ pub fn run() {
let monitor = MonitorKernel::new(app_data_dir.clone());
app.manage(monitor);
// 初始化 NetworkMonitor(网速监控,独立于 ThingHK Kernel
// 网速采样不依赖提权,应用启动即开始
let network_monitor = Arc::new(NetworkMonitor::new());
app.manage(network_monitor.clone());
network_monitor.start(app.handle().clone());
// 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/
let engine = DownloadEngine::new(
app_data_dir.join("downloader"),
@@ -179,6 +206,9 @@ pub fn run() {
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
@@ -241,6 +271,9 @@ pub fn run() {
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
+502 -7
View File
@@ -12,6 +12,7 @@
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -35,6 +36,155 @@ const READY_POLL_INTERVAL_MS: u64 = 500;
/// 冷启动就绪总超时(阶段一实测冷启动约 5s,留 5s 余量)
const READY_TIMEOUT_MS: u64 = 10_000;
// ===================== Windows 提权启动 FFI =====================
/// ShellExecuteW 的 nShowCmd 取值:隐藏窗口。
/// ThingHK.exe 是控制台程序,提权启动时隐藏控制台窗口避免黑框。
#[cfg(windows)]
const SW_HIDE: i32 = 0;
// ShellExecuteW 返回值 <= 32 表示错误(HINSTANCE 被强制为 isize)。
// 1223 = ERROR_CANCELLED(用户取消了 UAC 弹窗)。
#[cfg(windows)]
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(
hwnd: *mut std::ffi::c_void,
lp_operation: *const u16,
lp_file: *const u16,
lp_parameters: *const u16,
lp_directory: *const u16,
n_show_cmd: i32,
) -> isize;
}
/// 以管理员权限启动可执行文件(弹 UAC)。
/// 通过 ShellExecuteW "runas" 动词实现,返回的进程句柄不可用(跨权限级别),
/// 因此提权后的进程不加入 ProcessManager,停止时通过 Kernel 的 /shutdown 接口。
#[cfg(windows)]
fn shell_execute_elevated(file: &str, params: &str, dir: Option<&str>) -> Result<(), String> {
use std::os::windows::ffi::OsStrExt;
fn to_wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
let operation = to_wide("runas");
let file_w = to_wide(file);
let params_w = to_wide(params);
let dir_w = dir.map(to_wide);
let dir_ptr = dir_w.as_ref().map_or(std::ptr::null(), |d| d.as_ptr());
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
operation.as_ptr(),
file_w.as_ptr(),
params_w.as_ptr(),
dir_ptr,
SW_HIDE,
)
};
if result <= 32 {
// 1223 = ERROR_CANCELLED:用户点了"否"或关闭了 UAC 弹窗
if result == 1223 {
return Err("用户取消了 UAC 提权".into());
}
Err(format!("ShellExecuteW 失败,错误码: {}", result))
} else {
Ok(())
}
}
/// 检测当前 Thing 进程是否以管理员权限运行。
/// 用于"永久提权"模式:Thing 本身是管理员时,ThingHK 子进程自动继承权限,
/// 无需 ShellExecuteW "runas"ProcessManager 可直接管理(同权限级别可 kill)。
#[cfg(windows)]
pub fn is_thing_elevated() -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token: HANDLE = 0;
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
return false;
}
let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
let mut ret_len = 0u32;
let success = GetTokenInformation(
token,
TokenElevation,
&mut elevation as *mut _ as *mut _,
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut ret_len,
);
CloseHandle(token);
success != 0 && elevation.TokenIsElevated != 0
}
}
#[cfg(not(windows))]
pub fn is_thing_elevated() -> bool {
false
}
// ===================== 永久提权标志持久化 =====================
/// 永久提权标志文件路径: {app_data_dir}/monitor/elevate.json
/// 内容: { "enabled": true }
fn elevate_flag_path(app_data_dir: &std::path::Path) -> PathBuf {
app_data_dir.join("monitor").join("elevate.json")
}
/// 读取永久提权标志
fn read_elevate_flag(flag_path: &std::path::Path) -> bool {
match fs::read_to_string(flag_path) {
Ok(content) => serde_json::from_str::<serde_json::Value>(&content)
.ok()
.and_then(|v| v.get("enabled").and_then(|e| e.as_bool()))
.unwrap_or(false),
Err(_) => false,
}
}
/// 写入永久提权标志
fn write_elevate_flag(flag_path: &std::path::Path, enabled: bool) -> Result<(), String> {
if let Some(parent) = flag_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let content = serde_json::json!({ "enabled": enabled }).to_string();
fs::write(flag_path, content).map_err(|e| format!("写入提权标志失败: {}", e))
}
/// 启动时检查永久提权标志,如果已设置且当前非管理员,则 ShellExecute "runas" 重启自身。
/// 在 `setup` 早期调用(MonitorKernel 初始化之前)。
/// 返回 true 表示已触发提权重启,调用方应立即退出当前进程。
pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
let flag_path = elevate_flag_path(app_data_dir);
if !read_elevate_flag(&flag_path) {
return false;
}
if is_thing_elevated() {
return false; // 已是管理员,无需重启
}
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(_) => return false,
};
eprintln!("[monitor] 检测到永久提权标志,以管理员权限重启 Thing");
match shell_execute_elevated(&exe.to_string_lossy(), "", None) {
Ok(()) => true,
Err(e) => {
eprintln!("[monitor] 永久提权重启失败(用户可能取消了 UAC): {}", e);
false
}
}
}
// ===================== 数据结构 =====================
/// Kernel /status 响应(与 ThingHK Contracts.cs KernelStatus 对应)
@@ -73,6 +223,10 @@ pub struct MonitorStatus {
pub ready: bool,
pub sensor_count: u32,
pub restart_count: u32,
/// 是否为提权模式(通过 UAC 以管理员权限启动)
pub elevated: bool,
/// Thing 自身是否以管理员权限运行(永久提权模式)
pub thing_elevated: bool,
}
// ===================== MonitorKernel =====================
@@ -91,6 +245,8 @@ pub struct MonitorKernel {
sub_handle: Arc<Mutex<Option<tauri::async_runtime::JoinHandle<()>>>>,
/// 监听 process-status-changed 的句柄,用于在 stop 时取消监听
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
/// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown
elevated: Arc<AtomicBool>,
}
impl MonitorKernel {
@@ -108,9 +264,25 @@ impl MonitorKernel {
.unwrap_or_else(|_| Client::new()),
sub_handle: Arc::new(Mutex::new(None)),
listener_ids: Arc::new(Mutex::new(Vec::new())),
elevated: Arc::new(AtomicBool::new(false)),
}
}
/// 当前是否处于提权模式
pub fn is_elevated(&self) -> bool {
self.elevated.load(Ordering::SeqCst)
}
/// 永久提权标志是否已启用(后续启动自动触发 UAC)
pub fn elevate_on_launch(&self) -> bool {
read_elevate_flag(&self.root.join("elevate.json"))
}
/// 设置/清除永久提权标志
pub fn set_elevate_on_launch(&self, enabled: bool) -> Result<(), String> {
write_elevate_flag(&self.root.join("elevate.json"), enabled)
}
fn cores_dir(&self) -> PathBuf {
self.root.join("cores")
}
@@ -120,15 +292,23 @@ impl MonitorKernel {
fn kernel_url(&self) -> String {
format!("http://127.0.0.1:{}", KERNEL_PORT)
}
/// 硬件监控配置文件路径:{app_data_dir}/monitor/hardware-config.json
pub fn hardware_config_path(&self) -> PathBuf {
self.root.join("hardware-config.json")
}
/// 确保内核就位:若 cores/ 无内核,从资源目录复制
/// 确保内核就位:若 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() {
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
if src.exists() {
let need_copy = !kernel.exists()
|| fs::metadata(&src)
.and_then(|s| fs::metadata(&kernel).map(|d| s.len() != d.len()))
.unwrap_or(true);
if need_copy {
fs::create_dir_all(self.cores_dir()).ok();
fs::copy(&res, &kernel).map_err(|e| format!("复制 Kernel 失败: {}", e))?;
fs::copy(&src, &kernel).map_err(|e| format!("复制 Kernel 失败: {}", e))?;
}
}
}
@@ -155,7 +335,8 @@ impl MonitorKernel {
"serve".into(),
"--port".into(),
KERNEL_PORT.to_string(),
"--basic".into(),
"--config".into(),
self.hardware_config_path().to_string_lossy().to_string(),
],
cwd: Some(self.cores_dir().to_string_lossy().to_string()),
name: "ThingHK".into(),
@@ -273,6 +454,12 @@ impl MonitorKernel {
// 重连前先确认 Kernel 是否还活着(可能已被 stop)
if !self.is_kernel_alive().await {
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环");
// 提权模式下 Kernel 崩溃/退出后重置 elevated 标志,
// 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启
if self.is_elevated() {
self.elevated.store(false, Ordering::SeqCst);
eprintln!("[monitor] 提权 Kernel 已退出,重置 elevated 标志");
}
break;
}
}
@@ -345,7 +532,14 @@ impl MonitorKernel {
/// 注册 process-status-changed 监听:当 Kernel 进程被自动重启恢复 Running 时,
/// 自动重新启动 SSE 订阅(实现崩溃恢复后的自愈)。
/// 注册前先清理旧 listener,防止重复注册导致多次 spawn SSE 订阅。
pub async fn register_auto_reconnect(self: Self, app: AppHandle) {
// 清理旧 listener(防止重复注册)
let old_ids = self.listener_ids.lock().await.drain(..).collect::<Vec<_>>();
for id in old_ids {
app.unlisten(id);
}
let app_clone = app.clone();
let this = self.clone();
let id = app.listen("process-status-changed", move |event| {
@@ -371,14 +565,67 @@ impl MonitorKernel {
/// 启动 Kernel 进程并开始 SSE 订阅(命令和 setup 自动启动共用)。
/// ProcessManager 通过 app.state 获取,无需外部传入。
///
/// 启动前先检测端口 8730 是否已有 Kernel 在运行(可能是上次提权遗留的进程):
/// - 如果是提权 KernelisAdmin=true):直接接管,设置 elevated=true,跳过 ProcessManager 启动
/// - 如果是普通权限 Kernel:调用 /shutdown 停止后重新启动
///
/// 永久提权模式(Thing 自身是管理员):
/// - ThingHK 通过 ProcessManager 启动,子进程继承管理员权限
/// - 设置 elevated=true 表示 ThingHK 在管理员权限下运行
/// - ProcessManager 可直接 kill(同权限级别),无需 /shutdown 接口
pub async fn start_with_subscription(
&self,
app: &AppHandle,
) -> Result<crate::process_manager::ProcessInfo, String> {
let thing_elevated = is_thing_elevated();
// 检测是否已有 Kernel 在运行(端口被占用)
if let Ok(status) = self.get_status().await {
if status.ready {
if thing_elevated {
// Thing 是管理员:停止已有 Kernel(无论什么权限),用 ProcessManager 重启以继承权限
eprintln!("[monitor] Thing 已提权,重启 ThingHK 以继承管理员权限");
let _ = self.shutdown_kernel().await;
tokio::time::sleep(Duration::from_millis(500)).await;
} else if status.is_admin {
// Thing 非管理员,但已有提权 Kernel:直接接管
eprintln!("[monitor] 检测到已有提权 Kernel 运行中,直接接管");
self.elevated.store(true, Ordering::SeqCst);
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);
// 提权模式下无 ProcessInfo,返回一个占位的
return Ok(crate::process_manager::ProcessInfo {
id: PROCESS_ID.into(),
name: "ThingHK".into(),
status: crate::process_manager::ProcessStatus::Running,
pid: None,
restart_count: 0,
});
} else {
// Thing 非管理员,已有普通 Kernel:先停止
eprintln!("[monitor] 检测到已有普通权限 Kernel 运行中,先停止再重启");
let _ = self.shutdown_kernel().await;
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
let pm = app.state::<ProcessManager>();
let params = self.prepare_for_start(app)?;
let info = pm.start(params)?;
// Thing 是管理员时,ThingHK 继承权限,标记 elevated(仍由 ProcessManager 管理)
if thing_elevated {
self.elevated.store(true, Ordering::SeqCst);
eprintln!("[monitor] ThingHK 已以管理员权限启动(继承自 Thing)");
}
// 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅,
// 确保 register_auto_reconnect 注册的 listener 与 sub_handle 共享,
// stop_subscription 时才能正确清理 listener。
@@ -392,6 +639,117 @@ impl MonitorKernel {
Ok(info)
}
/// 以管理员权限启动 Kernel(弹 UAC)。
///
/// 流程:
/// 1. 停止当前普通权限 Kernel(如果在运行)+ 取消 SSE 订阅
/// 2. 等待端口释放
/// 3. ShellExecuteW "runas" 启动提权 Kernel(隐藏控制台窗口)
/// 4. 标记 elevated=true,注册自动重连 + 启动 SSE 订阅
///
/// 提权模式限制(与普通模式的差异):
/// - 进程不加入 ProcessManager(跨权限级别句柄不可用)
/// - 停止通过 Kernel 的 POST /shutdown 接口(普通权限无法 TerminateProcess 管理员进程)
/// - 崩溃不支持自动重启(ProcessManager 看不到此进程)
/// - pid 不可查询(返回 None
pub async fn start_elevated(&self, app: &AppHandle) -> Result<(), String> {
// 1. 停止当前 Kernel(可能是普通权限,也可能是已提权的)
self.stop_subscription(app).await;
let pm = app.state::<ProcessManager>();
if pm.get_status(PROCESS_ID).is_some() {
let _ = pm.stop(PROCESS_ID);
}
if self.elevated.load(Ordering::SeqCst) {
// 已是提权模式,先通过 /shutdown 停止旧进程
let _ = self.shutdown_kernel().await;
self.elevated.store(false, Ordering::SeqCst);
}
// 2. 准备 Kernel 二进制
let info = self.prepare_kernel(app)?;
if !info.exists {
return Err(format!(
"ThingHK Kernel 未安装。请将 ThingHK.exe 放置到 src-tauri/binaries/ 后重新构建。"
));
}
// 3. 等待端口释放(pm.stop 异步 kill 后 Windows 端口释放有延迟)
tokio::time::sleep(Duration::from_millis(800)).await;
// 4. ShellExecute "runas" 启动提权 Kernel
// 提权模式下控制台窗口隐藏(SW_HIDE),stderr 不可见,
// 通过 --log-file 将日志写入文件,便于排查崩溃问题
let log_path = self.root.join("kernel-elevated.log");
let config_path = self.hardware_config_path();
let params = format!(
"serve --port {} --config \"{}\" --log-file \"{}\"",
KERNEL_PORT,
config_path.to_string_lossy(),
log_path.to_string_lossy()
);
#[cfg(windows)]
{
shell_execute_elevated(
&info.path,
&params,
Some(&self.cores_dir().to_string_lossy()),
)?;
}
#[cfg(not(windows))]
{
let _ = params;
return Err("提权启动仅支持 Windows".into());
}
self.elevated.store(true, Ordering::SeqCst);
eprintln!("[monitor] 提权启动已发起,等待 Kernel ready");
// 5. 启动 SSE 订阅(提权模式不注册 register_auto_reconnect
// 提权进程不归 ProcessManager 管,process-status-changed 事件不会触发,
// 注册了反而可能在其他进程状态变化时误触发 SSE 重连)
let kernel = self.clone();
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(())
}
/// 调用 Kernel 的 POST /shutdown 接口,让提权 Kernel 自行退出。
/// 普通权限 Tauri 无法 TerminateProcess 管理员进程,必须通过 HTTP 优雅关闭。
async fn shutdown_kernel(&self) -> Result<(), String> {
let url = format!("{}/shutdown", self.kernel_url());
let resp = self
.client
.post(&url)
.timeout(Duration::from_secs(3))
.send()
.await
.map_err(|e| format!("请求 /shutdown 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("/shutdown 返回 HTTP {}", resp.status()));
}
// 等待 Kernel 进程退出 + 端口释放
tokio::time::sleep(Duration::from_millis(500)).await;
Ok(())
}
/// 程序退出时清理:取消 SSE 订阅 + 停止提权 Kernel。
/// 普通权限 Kernel 和永久提权模式(Thing 也是管理员)由 ProcessManager.stop_all 统一清理;
/// 仅"提权 ThingHK"模式(Thing 普通、ThingHK 管理员)需通过 /shutdown 接口停止,
/// 否则程序关闭后提权 Kernel 会残留并占用端口,导致下次启动端口冲突。
pub async fn cleanup_on_exit(&self, app: &AppHandle) {
self.stop_subscription(app).await;
// 仅当 ThingHK 提权但 Thing 未提权时才需要 /shutdownProcessManager 无法 kill 管理员进程)
if self.is_elevated() && !is_thing_elevated() {
eprintln!("[monitor] 退出清理:停止提权 Kernel/shutdown");
let _ = self.shutdown_kernel().await;
self.elevated.store(false, Ordering::SeqCst);
}
}
}
/// 解析 SSE 事件文本,提取 data: 字段的 JSON 内容。
@@ -425,12 +783,29 @@ pub async fn monitor_status(
state: tauri::State<'_, MonitorKernel>,
pm: tauri::State<'_, ProcessManager>,
) -> Result<MonitorStatus, String> {
let thing_elevated = is_thing_elevated();
// 提权模式(仅 ThingHK 提权,Thing 普通):进程不归 ProcessManager 管
if state.is_elevated() && !thing_elevated {
let kernel_status = state.get_status().await.ok();
let running = kernel_status.is_some();
return Ok(MonitorStatus {
running,
pid: None,
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: 0,
elevated: true,
thing_elevated: false,
});
}
// 普通权限模式 或 永久提权模式(Thing 管理员):通过 ProcessManager 获取进程状态
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,
@@ -438,6 +813,9 @@ pub async fn monitor_status(
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),
// 永久提权模式下 elevated=trueThingHK 在管理员权限运行)
elevated: thing_elevated,
thing_elevated,
})
}
@@ -449,6 +827,50 @@ pub async fn monitor_start(
state.start_with_subscription(&app).await
}
/// 以管理员权限启动 Kernel(弹 UAC)。
/// 提权后的进程不归 ProcessManager 管,停止需通过 monitor_stop(内部走 /shutdown)。
#[tauri::command]
pub async fn monitor_start_elevated(
state: tauri::State<'_, MonitorKernel>,
app: AppHandle,
) -> Result<(), String> {
state.start_elevated(&app).await
}
/// 永久提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。
/// - 已是管理员:仅设置标志,不重启
/// - 非管理员:设置标志 + ShellExecute "runas" 重启 + 退出当前进程
#[tauri::command]
pub async fn monitor_elevate_self(
state: tauri::State<'_, MonitorKernel>,
app: AppHandle,
) -> Result<(), String> {
#[cfg(windows)]
{
// 持久化标志:后续启动时 setup 检测到此标志会自动 ShellExecute "runas"
state.set_elevate_on_launch(true)?;
if is_thing_elevated() {
// 已是管理员,只需设置标志,无需重启
eprintln!("[monitor] Thing 已是管理员,仅设置永久提权标志");
return Ok(());
}
let exe = std::env::current_exe().map_err(|e| format!("获取当前路径失败: {}", e))?;
let exe_str = exe.to_string_lossy().to_string();
eprintln!("[monitor] 永久提权:以管理员权限重启 Thing");
shell_execute_elevated(&exe_str, "", None)?;
// 退出当前进程(非提权),新的提权进程会接管
app.exit(0);
Ok(())
}
#[cfg(not(windows))]
{
let _ = (state, app);
Err("永久提权仅支持 Windows".into())
}
}
#[tauri::command]
pub async fn monitor_stop(
state: tauri::State<'_, MonitorKernel>,
@@ -456,8 +878,16 @@ pub async fn monitor_stop(
app: AppHandle,
) -> Result<(), String> {
state.stop_subscription(&app).await;
// 仅"提权 ThingHK"模式(Thing 普通、ThingHK 管理员)需要 /shutdown
// 永久提权模式(Thing 管理员)和普通模式都用 ProcessManager kill
if state.is_elevated() && !is_thing_elevated() {
state.shutdown_kernel().await?;
state.elevated.store(false, Ordering::SeqCst);
Ok(())
} else {
pm.stop(PROCESS_ID)
}
}
#[tauri::command]
pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Result<KernelStatus, String> {
@@ -468,3 +898,68 @@ pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Resul
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
state.get_snapshot().await
}
/// 查询永久提权标志是否已启用
#[tauri::command]
pub fn monitor_get_elevate_on_launch(
state: tauri::State<'_, MonitorKernel>,
) -> bool {
state.elevate_on_launch()
}
/// 设置/清除永久提权标志。
/// 清除后,下次启动不再触发 UAC(当前会话权限不变)。
#[tauri::command]
pub fn monitor_set_elevate_on_launch(
state: tauri::State<'_, MonitorKernel>,
enabled: bool,
) -> Result<(), String> {
state.set_elevate_on_launch(enabled)
}
/// 查询硬件监控配置(透传 Kernel GET /config/hardware)。
/// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。
#[tauri::command]
pub async fn monitor_get_hardware_config(
state: tauri::State<'_, MonitorKernel>,
) -> Result<serde_json::Value, String> {
let url = format!("{}/config/hardware", state.kernel_url());
let resp = state
.client
.get(&url)
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| format!("请求 /config/hardware 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("/config/hardware 返回 HTTP {}", resp.status()));
}
resp.json().await.map_err(|e| format!("解析配置失败: {}", e))
}
/// 更新硬件监控配置(透传 Kernel POST /config/hardware)。
/// 传感器类型过滤热生效;硬件开关变化返回 restartRequired=true,需重启 Kernel。
#[tauri::command]
pub async fn monitor_set_hardware_config(
state: tauri::State<'_, MonitorKernel>,
hardware: Option<std::collections::HashMap<String, bool>>,
sensor_types: Option<std::collections::HashMap<String, bool>>,
) -> Result<serde_json::Value, String> {
let url = format!("{}/config/hardware", state.kernel_url());
let body = serde_json::json!({
"hardware": hardware,
"sensorTypes": sensor_types,
});
let resp = state
.client
.post(&url)
.timeout(Duration::from_secs(5))
.json(&body)
.send()
.await
.map_err(|e| format!("请求 POST /config/hardware 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("POST /config/hardware 返回 HTTP {}", resp.status()));
}
resp.json().await.map_err(|e| format!("解析响应失败: {}", e))
}
+123
View File
@@ -0,0 +1,123 @@
// network_monitor.rs — 网速监控模块
//
// 职责:
// - 后台每秒采样所有网络接口的收发字节数
// - 计算与上一次采样的差值(速率,bytes/s)
// - 聚合所有接口得到总下载/上传速率
// - 通过 Tauri 事件 "monitor-network" 推送给前端
//
// 设计说明:
// - 不依赖 ThingHKThingHK 当前不支持网络传感器扩展)
// - 使用 sysinfo crate 的 Networks(轻量、跨平台)
// - 采样间隔与 Kernel 快通道一致(1s),保证 UI 曲线流畅
// - 前端通过监听事件更新 UI,无需主动 invoke
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde::Serialize;
use sysinfo::Networks;
use tauri::{AppHandle, Emitter};
/// 推送给前端的网速数据
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct NetworkSpeed {
/// 下载速率(bytes/s),所有接口聚合
pub download_bps: u64,
/// 上传速率(bytes/s),所有接口聚合
pub upload_bps: u64,
/// 时间戳(ms
pub timestamp: u64,
}
/// 网速监控器:后台任务采样并 emit 事件。
///
/// 使用方式:
/// 1. app.manage(NetworkMonitor::new()) 注册到 Tauri State
/// 2. 在 setup 中调用 NetworkMonitor::start(&app) 启动后台采样
/// 3. 前端监听 "monitor-network" 事件
pub struct NetworkMonitor {
/// 是否正在运行(用于 start/stop 幂等控制)
running: Arc<AtomicBool>,
}
impl NetworkMonitor {
pub fn new() -> Self {
Self {
running: Arc::new(AtomicBool::new(false)),
}
}
/// 启动后台网速采样任务。幂等:重复调用不会启动多个任务。
pub fn start(self: Arc<Self>, app: AppHandle) {
if self.running.swap(true, Ordering::SeqCst) {
return; // 已在运行
}
let running = self.running.clone();
tauri::async_runtime::spawn(async move {
// sysinfo Networks 需要可变借用,放在独立线程的本地变量
let mut networks = Networks::new_with_refreshed_list();
// 首次采样仅记录基线,不 emit(速率需要两次采样差值)
let mut last_rx: u64 = networks.iter().map(|(_, data)| data.total_received()).sum();
let mut last_tx: u64 = networks.iter().map(|(_, data)| data.total_transmitted()).sum();
// 1s 间隔采样(与 Kernel 快通道一致)
let mut interval = tokio::time::interval(Duration::from_secs(1));
// 跳过第一次立即触发(已记录基线)
interval.tick().await;
loop {
interval.tick().await;
if !running.load(Ordering::SeqCst) {
break;
}
// 刷新网络数据(sysinfo 0.32: refresh() 刷新接口列表和统计数据)
networks.refresh();
let cur_rx: u64 = networks.iter().map(|(_, data)| data.total_received()).sum();
let cur_tx: u64 = networks.iter().map(|(_, data)| data.total_transmitted()).sum();
// 速率 = 当前累计 - 上次累计(sysinfo 的 total_received 是单调递增的累计值)
// 若接口移除/添加可能导致差值为负或异常大,做饱和减法保护
let download_bps = cur_rx.saturating_sub(last_rx);
let upload_bps = cur_tx.saturating_sub(last_tx);
last_rx = cur_rx;
last_tx = cur_tx;
let speed = NetworkSpeed {
download_bps,
upload_bps,
timestamp: chrono::Local::now().timestamp_millis() as u64,
};
// emit 给前端(失败忽略:窗口可能未就绪)
let _ = app.emit("monitor-network", &speed);
}
eprintln!("[network] 网速采样任务已退出");
});
}
/// 停止后台采样
#[allow(dead_code)]
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
}
}
impl Default for NetworkMonitor {
fn default() -> Self {
Self::new()
}
}
/// Tauri 命令:获取网速监控是否运行
#[tauri::command]
pub fn network_monitor_status(
state: tauri::State<'_, NetworkMonitor>,
) -> bool {
state.running.load(Ordering::SeqCst)
}
+608 -158
View File
@@ -2,10 +2,13 @@
import {
Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick,
Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle,
ShieldCheck, ShieldAlert, Zap, Thermometer, Clock, ChevronDown,
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
KeyRound, ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
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'
@@ -16,6 +19,9 @@ 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'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
const store = useMonitorStore()
@@ -31,9 +37,9 @@ const tabsListRef = useModuleTabs(activeTab, [
// 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次)
const HISTORY_LEN = 30
const cpuTempHistory = ref<number[]>([])
const cpuLoadHistory = ref<number[]>([])
const memLoadHistory = ref<number[]>([])
const gpuLoadHistory = ref<number[]>([])
const gpuTempHistory = ref<number[]>([])
const netDownHistory = ref<number[]>([])
const netUpHistory = ref<number[]>([])
function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
if (v == null || !isFinite(v)) return
@@ -41,31 +47,112 @@ function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
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 =====
/** GPU 分组 id(兼容 intel/amd/nvidia */
const gpuGroupId = computed(() => {
if (store.groupById['gpuintel']) return 'gpuintel'
if (store.groupById['gpuamd']) return 'gpuamd'
if (store.groupById['gpunvidia']) return 'gpunvidia'
return null
})
const cpuModel = computed(() => store.groupById['cpu']?.sensors[0]?.hardwareName ?? null)
const cpuTemp = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
const cpuPower = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'power' }))
const gpuModel = computed(() => {
const gid = gpuGroupId.value
return gid ? store.groupById[gid]?.sensors[0]?.hardwareName ?? null : null
})
const gpuTemp = computed(() => {
const gid = gpuGroupId.value
if (!gid) return null
// GPU 温度传感器命名因厂商而异,按优先级匹配
return store.findSensorValue(gid, { name: 'GPU Core', type: 'temperature' })
?? store.findSensorValue(gid, { name: 'GPU Temperature', type: 'temperature' })
?? store.findSensorValue(gid, { type: 'temperature' })
})
const gpuLoad = computed(() => {
const gid = gpuGroupId.value
if (!gid) return null
return store.findSensorValue(gid, { name: 'D3D 3D', type: 'load' })
?? store.findSensorValue(gid, { name: 'GPU Core', type: 'load' })
?? store.findSensorValue(gid, { type: 'load' })
})
const gpuPower = computed(() => {
const gid = gpuGroupId.value
if (!gid) return null
return store.findSensorValue(gid, { name: 'GPU Power', type: 'power' })
?? store.findSensorValue(gid, { type: 'power' })
})
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' }))
const memUsedGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Used', type: 'data' }))
const memAvailGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Available', type: 'data' }))
const memTotalGB = computed(() => {
const used = memUsedGB.value
const avail = memAvailGB.value
if (used != null && avail != null) return used + avail
return null
})
/** 物理内存条型号列表(排除 Virtual Memory / Total Memory 虚拟分组) */
const memModuleModels = computed(() => {
const g = store.groupById['memory']
if (!g) return []
const models = new Set<string>()
for (const s of g.sensors) {
if (s.hardwareName !== 'Virtual Memory' && s.hardwareName !== 'Total Memory') {
models.add(s.hardwareName)
}
}
return Array.from(models)
})
/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量) */
interface StorageDrive {
name: string
temp: number | null
usedPct: number | null
totalGB: number | null
usedGB: number | null
}
const storageDrives = computed<StorageDrive[]>(() => {
const g = store.groupById['storage']
if (!g) return []
const byHw = new Map<string, SensorEntry[]>()
for (const s of g.sensors) {
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
byHw.get(s.hardwareName)!.push(s)
}
return Array.from(byHw.entries()).map(([hw, sensors]) => {
const temp = sensors.find(s => s.type === 'temperature')?.value ?? null
const usedPct = sensors.find(s => s.name === 'Used Space')?.value ?? null
const totalGB = sensors.find(s => s.name === 'Total Space')?.value ?? null
const freeGB = sensors.find(s => s.name === 'Free Space')?.value ?? null
const usedGB = totalGB != null && freeGB != null ? totalGB - freeGB : null
return { name: hw, temp, usedPct, totalGB, usedGB }
})
})
// 监听快照变化,提取温度历史(用于 CPU/GPU 温度 sparkline
watch(() => store.snapshot, () => {
pushHistory(cpuTempHistory, cpuTemp.value)
pushHistory(gpuTempHistory, gpuTemp.value)
}, { deep: false })
// 监听网速事件,记录历史(用于网速 sparkline)
watch(() => store.networkSpeed, (v) => {
if (v) {
pushHistory(netDownHistory, v.downloadBps)
pushHistory(netUpHistory, v.uploadBps)
}
}, { deep: false })
// 网速格式化(computed 避免模板中重复调用)
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== 工具函数 =====
@@ -92,6 +179,14 @@ function loadColor(v: number | null): string {
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
@@ -214,8 +309,151 @@ async function handleRefresh() {
}
}
/** 提权启动 Kernel(弹 UAC)。
* 普通权限下 CPU 温度/时钟/存储等传感器不可读,提权后可获取完整数据。
*/
async function handleStartElevated() {
await store.startElevated()
if (store.status?.elevated) {
toast.success('Kernel 已提权运行')
} else if (store.errorMsg) {
toast.error('提权失败', { description: store.errorMsg })
}
}
/** 永久提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。
* 非管理员时进程退出;已是管理员时仅设置标志并返回。
*/
async function handleElevateSelf() {
await store.elevateSelf()
// 非管理员时进程已退出,不会走到这里
if (store.errorMsg) {
toast.error('永久提权失败', { description: store.errorMsg })
} else if (store.elevateOnLaunch) {
toast.success('已启用永久提权', { description: '后续启动将自动以管理员权限运行' })
}
}
/** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */
async function handleCancelElevation() {
await store.cancelElevation()
if (!store.errorMsg) {
toast.success('已取消永久提权', { description: '下次启动将以普通权限运行' })
}
}
// ===== Kernel 路径显示 + 复制 + 打开文件夹(参考代理模块 mihomo 信息板块) =====
const appDataPath = ref('')
/** 路径缩略显示:只显示最后 3 段 */
const pathShort = computed(() => {
const p = store.kernelInfo?.path
if (!p) return '—'
const parts = p.replace(/\\/g, '/').split('/')
if (parts.length <= 4) return p
return '.../' + parts.slice(-3).join('/')
})
/** 路径完整显示:将 appData 部分替换为 %APPDATA% 便于在文件管理器地址栏打开 */
const pathDisplay = computed(() => {
const p = store.kernelInfo?.path
if (!p) return ''
if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) {
return '%APPDATA%' + p.slice(appDataPath.value.length)
}
return p
})
async function copyPath() {
const p = store.kernelInfo?.path
if (!p) return
try {
await navigator.clipboard.writeText(p)
toast.success('路径已复制', { description: pathDisplay.value })
} catch {
toast.error('复制失败')
}
}
async function openFolder() {
const p = store.kernelInfo?.path
if (!p) return
try {
await revealItemInDir(p)
} catch (e) {
toast.error('打开文件夹失败', { description: String(e) })
}
}
// ===== 硬件监控配置 Dialog =====
const configDialogOpen = ref(false)
/** Dialog 内编辑中的硬件开关(key → bool) */
const editingHardware = ref<Record<string, boolean>>({})
/** Dialog 内编辑中的传感器类型开关(key → bool) */
const editingSensorTypes = ref<Record<string, boolean>>({})
const savingConfig = ref(false)
async function openConfigDialog() {
await store.fetchHardwareConfig()
if (store.hardwareConfig) {
// 深拷贝当前配置到编辑状态
editingHardware.value = { ...store.hardwareConfig.config.hardware }
editingSensorTypes.value = { ...store.hardwareConfig.config.sensorTypes }
}
configDialogOpen.value = true
}
async function handleSaveConfig() {
savingConfig.value = true
try {
const resp = await store.saveHardwareConfig(editingHardware.value, editingSensorTypes.value)
if (resp?.success) {
if (resp.restartRequired) {
// 检测是否启用了需要管理员权限的硬件但当前非管理员
const needsAdmin = store.hardwareConfig?.availableHardware.some(
hw => editingHardware.value[hw.key] && hw.requiresAdmin
) ?? false
const isElevated = store.status?.elevated || store.status?.thingElevated
configDialogOpen.value = false
if (needsAdmin && !isElevated) {
toast.warning('部分硬件需要管理员权限', {
description: '主板/存储等硬件需提权才能读取完整数据,建议永久提权',
duration: 6000,
})
} else {
toast.success('配置已保存', { description: '正在重启 Kernel...' })
}
// 自动重启 Kernel 以应用硬件开关变更
await store.stop()
await new Promise(r => setTimeout(r, 800))
await store.start()
// 等待 Kernel 就绪后拉取快照(cold start 可能需要数秒)
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 500))
await store.refreshStatus()
if (store.status?.ready) {
await store.fetchSnapshot()
break
}
}
} else {
toast.success('配置已保存', { description: '传感器类型过滤已热生效' })
configDialogOpen.value = false
// 热更新后立即拉取新快照以反映传感器类型过滤变化
await store.fetchSnapshot()
}
} else if (store.errorMsg) {
toast.error('保存失败', { description: store.errorMsg })
}
} finally {
savingConfig.value = false
}
}
// 生命周期
onMounted(() => {
onMounted(async () => {
// 获取 appData 路径,用于将 Kernel 路径替换为 %APPDATA% 形式
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
store.init()
})
@@ -245,105 +483,42 @@ watch(() => store.status?.ready, (ready, prev) => {
<!-- ===== 概览 ===== -->
<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>
<!-- 始终显示卡片网格未启动时数据以占位符显示保持画面完整 -->
<div v-else key="content" class="grid grid-cols-1 md:grid-cols-3 gap-2.5">
<!-- CPU温度 + 功耗 + 曲线图未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Cpu class="size-4 text-primary" />CPU</span>
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="cpuModel ?? ''">{{ cpuModel ?? '--' }}</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">
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 + 曲线图 -->
<div class="flex items-end justify-between gap-2">
<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)]">
<div :class="['text-2xl font-bold tabular-nums leading-tight', 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" />
<div class="text-right">
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
<div class="text-base font-medium tabular-nums">{{ fmt(cpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
</div>
<svg v-if="cpuTempHistory.length >= 2" :width="56" :height="24" class="opacity-70 shrink-0">
<path :d="sparklinePath(cpuTempHistory, { w: 56, h: 24 })" 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">
<div class="flex items-center justify-between text-xs mb-0.5">
<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>
@@ -352,64 +527,203 @@ watch(() => store.status?.ready, (ready, prev) => {
</CardContent>
</Card>
<!-- 内存 -->
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MemoryStick class="size-4 text-primary" />内存
<!-- GPU结构与 CPU 一致温度 + 功耗 + 曲线图未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Gauge class="size-4 text-primary" />GPU</span>
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="gpuModel ?? ''">{{ gpuModel ?? '--' }}</span>
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 + 曲线图 CPU 卡片结构一致 -->
<div class="flex items-end justify-between gap-2">
<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 class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />核心温度</div>
<div :class="['text-2xl font-bold tabular-nums leading-tight', tempColor(gpuTemp)]">
{{ fmt(gpuTemp, 0) }}<span class="text-sm font-normal">°C</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" />
<div class="text-right">
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
<div class="text-base font-medium tabular-nums">{{ fmt(gpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
</div>
<svg v-if="gpuTempHistory.length >= 2" :width="56" :height="24" class="opacity-70 shrink-0">
<path :d="sparklinePath(gpuTempHistory, { w: 56, h: 24 })" stroke="currentColor" :class="tempColor(gpuTemp)" 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>
</div>
<!-- 负载 -->
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />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">
<!-- 内存主指标大字 + 进度条未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><MemoryStick class="size-4 text-primary" />内存</span>
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="memModuleModels.join(', ')">
{{ memTotalGB != null ? fmt(memTotalGB, 0) + ' GB' : '--' }}
</span>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 已使用 / 总容量主指标 CPU 温度对齐 -->
<div class="flex items-end justify-between gap-2">
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1"><MemoryStick class="size-3" />已使用</div>
<div class="text-2xl font-bold tabular-nums leading-tight">
{{ fmt(memUsedGB, 1) }}<span class="text-sm font-normal text-muted-foreground"> / {{ fmt(memTotalGB, 1) }} GB</span>
</div>
</div>
</div>
<!-- 负载进度条 CPU 负载对齐 -->
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />负载</span>
<span :class="['font-medium tabular-nums', loadColor(memLoad)]">{{ fmt(memLoad, 0) }}%</span>
</div>
<Progress :model-value="memLoad ?? 0" class="h-1.5" />
</div>
</CardContent>
</Card>
<!-- 网络跨3列下载/上传 + 曲线图独立于 Kernel Tauri 后台推送 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
<Wifi class="size-4 text-primary" />网络
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div class="grid grid-cols-2 gap-4">
<!-- 下载 -->
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowDown class="size-3 text-sky-500" />下载</div>
<div class="flex items-end gap-2">
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500 shrink-0">
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
</div>
<svg v-if="netDownHistory.length >= 2" class="flex-1 h-7 opacity-70" viewBox="0 0 80 28" preserveAspectRatio="none">
<path :d="sparklinePath(netDownHistory, { w: 80, h: 28 })" stroke="currentColor" class="text-sky-500" fill="none" stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
</div>
</div>
<!-- 上传 -->
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowUp class="size-3 text-violet-500" />上传</div>
<div class="flex items-end gap-2">
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500 shrink-0">
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
</div>
<svg v-if="netUpHistory.length >= 2" class="flex-1 h-7 opacity-70" viewBox="0 0 80 28" preserveAspectRatio="none">
<path :d="sparklinePath(netUpHistory, { w: 80, h: 28 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 存储跨3列列出各硬盘温度/容量/使用率未读到以占位符显示 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
<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>
<CardContent class="px-3.5 pb-2.5">
<div v-if="storageDrives.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<div v-for="drive in storageDrives" :key="drive.name" class="border rounded-md p-2 space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-medium truncate" :title="drive.name">{{ drive.name }}</span>
<span :class="['text-xs font-mono tabular-nums shrink-0', tempColor(drive.temp)]">{{ fmt(drive.temp, 0) }}°C</span>
</div>
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground">使用率</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedPct, 0) }}%</span>
</div>
<Progress :model-value="drive.usedPct ?? 0" class="h-1" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>容量</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedGB, 1) }} / {{ fmt(drive.totalGB, 1) }} GB</span>
</div>
</div>
</div>
<!-- 无数据占位保持卡片结构完整 -->
<div v-else class="text-xs text-muted-foreground py-2 text-center">暂无存储数据</div>
</CardContent>
</Card>
<!-- Kernel 状态卡片跨3列含启动/停止/刷新/提权按钮 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Activity class="size-4 text-primary" />Kernel 状态</span>
<div class="flex items-center gap-1.5">
<span :class="['text-xs px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
{{ stateMeta[store.connState].text }}
</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="text-xs border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />永久提权
</Badge>
<Badge v-else-if="store.status?.elevated" variant="outline" class="text-xs border-violet-500/50 text-violet-600 dark:text-violet-400">
<KeyRound class="size-2.5 mr-0.5" />提权
</Badge>
</div>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs">
<span class="text-muted-foreground">PID: <span class="font-mono text-foreground">{{ store.status?.pid ?? '--' }}</span></span>
<span class="text-muted-foreground">传感器: <span class="font-mono text-foreground">{{ store.status?.sensorCount ?? '--' }}</span></span>
<span class="text-muted-foreground">重启: <span class="font-mono text-foreground">{{ store.status?.restartCount ?? 0 }}</span></span>
<span class="text-muted-foreground">事件: <span class="font-mono text-foreground">{{ store.eventCount }}</span></span>
<div class="flex items-center gap-1.5 ml-auto">
<!-- 启动按钮未运行时显示 -->
<Button v-if="store.connState === 'idle'" size="xs" :disabled="store.starting" @click="handleStart">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<Play v-else class="size-3" />启动
</Button>
<!-- 普通提权按钮仅提权 ThingHKThing 非管理员且 ThingHK 未提权时显示 -->
<Button v-if="!store.snapshot?.isAdmin && !store.status?.elevated && !store.status?.thingElevated" size="xs" variant="outline" class="gap-1 text-orange-600 dark:text-orange-400 border-orange-500/40 hover:bg-orange-500/10" :disabled="store.starting" title="以管理员权限启动 Kernel(弹 UAC,仅提权 ThingHK" @click="handleStartElevated">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<KeyRound v-else class="size-3" />提权
</Button>
<!-- 永久提权按钮标志未启用时显示设置标志 + 以管理员权限重启 Thing -->
<Button v-if="!store.elevateOnLaunch" size="xs" variant="outline" class="gap-1 text-emerald-600 dark:text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" :disabled="store.starting" title="以管理员权限重启 Thing(弹 UAC,ThingHK 子进程继承权限,后续启动自动提权)" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<ShieldCheck v-else class="size-3" />永久提权
</Button>
<!-- 取消永久提权按钮标志已启用时显示清除标志下次启动不触发 UAC -->
<Button v-else size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" title="取消永久提权,下次启动将以普通权限运行(不影响当前会话)" @click="handleCancelElevation">
<ShieldOff class="size-3" />取消提权
</Button>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleRefresh">
<RefreshCw class="size-3" />刷新
</Button>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<Square class="size-3" />停止
</Button>
</div>
</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">
<Card v-if="store.connState === 'disconnected'" class="md:col-span-3 border-orange-500/40 py-0 gap-0">
<CardContent class="pt-3 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>
@@ -419,8 +733,8 @@ watch(() => store.status?.ready, (ready, prev) => {
</Card>
<!-- 错误提示 -->
<Card v-if="store.errorMsg" class="border-red-500/40">
<CardContent class="pt-4 flex items-start gap-2 text-sm">
<Card v-if="store.errorMsg" class="md:col-span-3 border-red-500/40 py-0 gap-0">
<CardContent class="pt-3 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>
@@ -513,6 +827,9 @@ watch(() => store.status?.ready, (ready, prev) => {
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="handleRefresh">
<RefreshCw class="size-3.5" />刷新状态
</Button>
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="openConfigDialog">
<ListChecks class="size-3.5" />监控项
</Button>
</div>
<div class="text-xs text-muted-foreground">
Kernel ProcessManager 统一管理应用启动时自动拉起崩溃自动重启
@@ -538,9 +855,38 @@ watch(() => store.status?.ready, (ready, prev) => {
{{ store.kernelInfo.exists ? '是' : '否' }}
</Badge>
</div>
<div class="flex justify-between items-start gap-2">
<div class="flex items-center justify-between gap-3">
<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 class="flex items-center gap-1.5 min-w-0">
<Tooltip>
<TooltipTrigger as-child>
<span
v-if="store.kernelInfo.exists"
class="font-mono text-xs text-right truncate cursor-default"
>{{ pathDisplay || pathShort }}</span>
<span v-else class="font-mono text-xs text-right cursor-default">{{ pathShort }}</span>
</TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ store.kernelInfo.path ?? '' }}</TooltipContent>
</Tooltip>
<template v-if="store.kernelInfo.exists">
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon-xs" variant="ghost" @click="copyPath">
<Copy class="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>复制路径</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon-xs" variant="ghost" @click="openFolder">
<FolderOpen class="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>在文件夹中显示</TooltipContent>
</Tooltip>
</template>
</div>
</div>
</CardContent>
</Card>
@@ -552,16 +898,48 @@ watch(() => store.status?.ready, (ready, prev) => {
<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>
<CardContent class="space-y-3 text-sm text-muted-foreground">
<div class="flex items-center justify-between">
<span>当前权限</span>
<div class="flex items-center gap-1.5">
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />永久提权
</Badge>
<Badge v-else-if="store.status?.elevated" variant="outline" class="border-violet-500/50 text-violet-600 dark:text-violet-400">
<KeyRound class="size-2.5 mr-0.5" />提权模式
</Badge>
</div>
</div>
<Separator />
<p class="text-xs leading-relaxed">
LibreHardwareMonitor 访问 SMBus部分 EC 传感器某些 GPU 传感器需要管理员权限
默认非提权运行覆盖大部分 CPU/GPU 温度通过 OHM RPC/WMI 仍可读牺牲部分主板/电压传感器
LibreHardwareMonitor 访问 CPU MSR温度/时钟存储 SMARTSMBusEC 传感器需要管理员权限
普通权限下可读CPU 负载/功率内存GPU 负载不可读CPU 温度/时钟存储主板/电压
</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>
<!-- 两种提权模式说明 -->
<div class="space-y-2">
<div class="flex items-start gap-2">
<KeyRound class="size-3.5 mt-0.5 shrink-0 text-orange-500" />
<div class="text-xs">
<span class="font-medium text-foreground">提权</span>仅以管理员权限启动 ThingHK UACThingHK 崩溃不自动重启停止走 /shutdown 接口
</div>
</div>
<div class="flex items-start gap-2">
<ShieldCheck class="size-3.5 mt-0.5 shrink-0 text-emerald-500" />
<div class="text-xs">
<span class="font-medium text-foreground">永久提权</span>以管理员权限重启 Thing 自身ThingHK 子进程继承权限标志持久化后续每次启动自动触发 UAC可在概览页点击"取消提权"关闭
</div>
</div>
</div>
<p v-if="store.status?.thingElevated" class="text-xs leading-relaxed text-emerald-600 dark:text-emerald-400 flex items-start gap-1.5">
<ShieldCheck class="size-3.5 mt-0.5 shrink-0" />
<span>永久提权模式Thing ThingHK 均以管理员权限运行ProcessManager 可直接 kill崩溃自动重启可用</span>
</p>
<p v-else-if="store.status?.elevated" class="text-xs leading-relaxed text-violet-600 dark:text-violet-400 flex items-start gap-1.5">
<KeyRound class="size-3.5 mt-0.5 shrink-0" />
<span>提权模式运行中 ThingHK 管理员权限进程不归 ProcessManager 管控崩溃不自动重启停止通过 /shutdown 接口优雅退出</span>
</p>
</CardContent>
</Card>
@@ -590,4 +968,76 @@ watch(() => store.status?.ready, (ready, prev) => {
</TabsContent>
</Tabs>
</div>
<!-- 硬件监控配置 Dialog -->
<Dialog v-model:open="configDialogOpen">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ListChecks class="size-4 text-primary" />监控项配置
</DialogTitle>
<DialogDescription>
勾选需要监控的硬件和传感器类型硬件开关变更需重启 Kernel传感器类型过滤立即生效
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[60vh] pr-4">
<div class="space-y-4">
<!-- 硬件分组 -->
<div class="space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">硬件分组</h4>
<div class="space-y-1.5">
<div
v-for="hw in store.hardwareConfig?.availableHardware ?? []"
:key="hw.key"
class="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<div class="flex items-center gap-2 min-w-0">
<Checkbox
:id="'hw-' + hw.key"
:model-value="editingHardware[hw.key] ?? false"
@update:model-value="editingHardware[hw.key] = Boolean($event)"
/>
<label :for="'hw-' + hw.key" class="text-sm cursor-pointer truncate">{{ hw.name }}</label>
<span v-if="hw.key === 'motherboard'" class="text-[10px] text-muted-foreground/60"> SuperIO/EC自动联动</span>
</div>
<Badge v-if="hw.requiresAdmin" variant="outline" class="text-[10px] shrink-0 text-amber-600 border-amber-500/40">
需管理员
</Badge>
</div>
</div>
</div>
<Separator />
<!-- 传感器类型 -->
<div class="space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">传感器类型</h4>
<div class="grid grid-cols-2 gap-1.5">
<div
v-for="st in store.hardwareConfig?.availableSensorTypes ?? []"
:key="st.key"
class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox
:id="'st-' + st.key"
:model-value="editingSensorTypes[st.key] ?? false"
@update:model-value="editingSensorTypes[st.key] = Boolean($event)"
/>
<label :for="'st-' + st.key" class="text-sm cursor-pointer">{{ st.name }}</label>
</div>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="configDialogOpen = false">取消</Button>
<Button :disabled="savingConfig" @click="handleSaveConfig">
<Loader2 v-if="savingConfig" class="size-3.5 animate-spin" />
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+178 -5
View File
@@ -42,6 +42,10 @@ export interface MonitorStatus {
ready: boolean
sensorCount: number
restartCount: number
/** 是否为提权模式(通过 UAC 以管理员权限启动) */
elevated: boolean
/** Thing 自身是否以管理员权限运行(永久提权模式) */
thingElevated: boolean
}
export interface MonitorKernelInfo {
@@ -50,6 +54,48 @@ export interface MonitorKernelInfo {
port: number
}
// ===== 硬件监控配置(与 ThingHK HardwareConfig.cs 对应) =====
export interface HardwareTypeInfo {
key: string
name: string
enabled: boolean
requiresAdmin: boolean
}
export interface SensorTypeInfo {
key: string
name: string
enabled: boolean
}
export interface HardwareConfig {
hardware: Record<string, boolean>
sensorTypes: Record<string, boolean>
}
export interface HardwareConfigResponse {
config: HardwareConfig
availableHardware: HardwareTypeInfo[]
availableSensorTypes: SensorTypeInfo[]
}
export interface HardwareConfigUpdateResponse {
success: boolean
restartRequired: boolean
configPath: string | null
}
/** 网速数据(由 Tauri network_monitor 模块推送,独立于 ThingHK Kernel */
export interface NetworkSpeed {
/** 下载速率(bytes/s */
downloadBps: number
/** 上传速率(bytes/s */
uploadBps: number
/** 时间戳(ms */
timestamp: number
}
/** 连接状态机:与后端事件一一对应 */
export type ConnectionState = 'idle' | 'loading' | 'connected' | 'disconnected' | 'error'
@@ -70,9 +116,18 @@ export const useMonitorStore = defineStore('monitor', () => {
const starting = ref(false)
const stopping = ref(false)
/** 网速数据(由 network_monitor 后台任务推送,独立于 Kernel) */
const networkSpeed = ref<NetworkSpeed | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */
const initialized = ref(false)
/** 永久提权标志是否已启用(后续启动自动触发 UAC) */
const elevateOnLaunch = ref(false)
/** 硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */
const hardwareConfig = ref<HardwareConfigResponse | null>(null)
let unlistenFns: UnlistenFn[] = []
// ===== getters =====
@@ -122,8 +177,6 @@ export const useMonitorStore = defineStore('monitor', () => {
errorMsg.value = null
} catch (e) {
logger.error('获取状态失败: ' + e)
} finally {
initialized.value = true
}
return status.value
}
@@ -169,6 +222,96 @@ export const useMonitorStore = defineStore('monitor', () => {
}
}
/** 以管理员权限重启 Kernel(弹 UAC)。
* 停止当前 Kernel → ShellExecute "runas" → 等待 ready → 重新订阅 SSE。
* 提权后进程不归 ProcessManager 管,停止走 /shutdown 接口。
*/
async function startElevated() {
if (starting.value) return
starting.value = true
errorMsg.value = null
try {
await invoke('monitor_start_elevated')
await refreshStatus()
} catch (e) {
errorMsg.value = String(e)
logger.error('提权启动失败: ' + e)
} finally {
starting.value = false
}
}
/** 永久提权:以管理员权限重启 Thing 自身,ThingHK 子进程会继承管理员权限。
* 非管理员时进程退出;已是管理员时仅设置标志并返回。 */
async function elevateSelf() {
if (starting.value) return
starting.value = true
errorMsg.value = null
try {
await invoke('monitor_elevate_self')
// 命令返回说明已是管理员(非管理员时进程已 exit,不会走到这里)
await refreshElevateOnLaunch()
} catch (e) {
errorMsg.value = String(e)
logger.error('永久提权失败: ' + e)
} finally {
starting.value = false
}
}
/** 刷新永久提权标志状态 */
async function refreshElevateOnLaunch() {
try {
elevateOnLaunch.value = await invoke<boolean>('monitor_get_elevate_on_launch')
} catch (e) {
logger.error('获取提权标志失败: ' + e)
}
return elevateOnLaunch.value
}
/** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */
async function cancelElevation() {
try {
await invoke('monitor_set_elevate_on_launch', { enabled: false })
await refreshElevateOnLaunch()
} catch (e) {
errorMsg.value = String(e)
logger.error('取消提权失败: ' + e)
}
}
/** 拉取硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */
async function fetchHardwareConfig() {
try {
hardwareConfig.value = await invoke<HardwareConfigResponse>('monitor_get_hardware_config')
} catch (e) {
logger.error('获取硬件配置失败: ' + e)
}
return hardwareConfig.value
}
/** 保存硬件监控配置。
* 传感器类型过滤热生效;硬件开关变化返回 restartRequired=true。
* 调用方负责根据 restartRequired 决定是否重启 Kernel。 */
async function saveHardwareConfig(
hardware: Record<string, boolean>,
sensorTypes: Record<string, boolean>,
): Promise<HardwareConfigUpdateResponse | null> {
try {
const resp = await invoke<HardwareConfigUpdateResponse>('monitor_set_hardware_config', {
hardware,
sensorTypes,
})
// 刷新本地缓存的配置
await fetchHardwareConfig()
return resp
} catch (e) {
errorMsg.value = String(e)
logger.error('保存硬件配置失败: ' + e)
return null
}
}
/** 主动拉取一次性快照(切回 tab 时立即填充,不等下一个 SSE tick) */
async function fetchSnapshot() {
try {
@@ -196,7 +339,8 @@ export const useMonitorStore = defineStore('monitor', () => {
refreshStatus()
}))
unlistenFns.push(await listen('monitor-loading', () => {
// 状态由 status 轮询反映
// 后端正在等待 Kernel ready,刷新状态以反映 running=true
refreshStatus()
}))
unlistenFns.push(await listen('monitor-disconnected', () => {
logger.warn('SSE 断开,等待自动重连')
@@ -206,6 +350,10 @@ export const useMonitorStore = defineStore('monitor', () => {
errorMsg.value = e.payload?.message ?? 'Kernel 错误'
logger.error('Kernel 错误: ' + JSON.stringify(e.payload))
}))
// 网速监控事件(独立于 Kernel,应用启动即推送)
unlistenFns.push(await listen<NetworkSpeed>('monitor-network', (e) => {
networkSpeed.value = e.payload
}))
}
function unsubscribe() {
@@ -213,14 +361,30 @@ export const useMonitorStore = defineStore('monitor', () => {
unlistenFns = []
}
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照 */
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照
* 首次加载期间 initialized=falseUI 显示 loading 占位(隐藏启动按钮等),
* 避免与后端 setup 异步自动启动竞态导致按钮误显示。 */
async function init() {
await Promise.all([refreshStatus(), refreshKernelInfo()])
try {
await Promise.all([refreshStatus(), refreshKernelInfo(), refreshElevateOnLaunch()])
await subscribe()
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
if (status.value?.ready) {
await fetchSnapshot()
}
// 自动启动竞态修复:setup 中 start_with_subscription 是异步 spawn
// 首次 refreshStatus 可能返回 running=false(进程还未拉起)。
// 若状态仍为 idle,短暂重试以等待自动启动生效。
if (!status.value?.running) {
for (let i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 500))
await refreshStatus()
if (status.value?.running) break
}
}
} finally {
initialized.value = true
}
}
/** 模块卸载时调用:仅取消事件订阅,不停止 KernelKernel 由 ProcessManager 全局管理) */
@@ -239,6 +403,9 @@ export const useMonitorStore = defineStore('monitor', () => {
starting,
stopping,
initialized,
networkSpeed,
elevateOnLaunch,
hardwareConfig,
// getters
connState,
isLive,
@@ -247,7 +414,13 @@ export const useMonitorStore = defineStore('monitor', () => {
findSensorValue,
refreshStatus,
refreshKernelInfo,
refreshElevateOnLaunch,
start,
startElevated,
elevateSelf,
cancelElevation,
fetchHardwareConfig,
saveHardwareConfig,
stop,
fetchSnapshot,
init,