监控模块 优化
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 为 HardwareConfigKey(cpu/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>配置 key(cpu/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>配置 key(temperature/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
@@ -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 行为:主板的传感器实际通过 SubHardware(SuperIO/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
@@ -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", "GPU(Intel/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
@@ -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();
|
||||
|
||||
@@ -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 提权模式限制
|
||||
|
||||
| 能力 | 普通模式 | 提权模式 |
|
||||
|------|----------|----------|
|
||||
| 进程管理 | ProcessManager(kill/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` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。
|
||||
|
||||
@@ -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)
|
||||
Generated
+136
-14
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 在运行(可能是上次提权遗留的进程):
|
||||
/// - 如果是提权 Kernel(isAdmin=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,
|
||||
¶ms,
|
||||
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 未提权时才需要 /shutdown(ProcessManager 无法 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=true(ThingHK 在管理员权限运行)
|
||||
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,7 +878,15 @@ pub async fn monitor_stop(
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
state.stop_subscription(&app).await;
|
||||
pm.stop(PROCESS_ID)
|
||||
// 仅"提权 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]
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// network_monitor.rs — 网速监控模块
|
||||
//
|
||||
// 职责:
|
||||
// - 后台每秒采样所有网络接口的收发字节数
|
||||
// - 计算与上一次采样的差值(速率,bytes/s)
|
||||
// - 聚合所有接口得到总下载/上传速率
|
||||
// - 通过 Tauri 事件 "monitor-network" 推送给前端
|
||||
//
|
||||
// 设计说明:
|
||||
// - 不依赖 ThingHK(ThingHK 当前不支持网络传感器扩展)
|
||||
// - 使用 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)
|
||||
}
|
||||
@@ -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">
|
||||
<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>
|
||||
<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"><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>
|
||||
</div>
|
||||
<Progress :model-value="memLoad ?? 0" class="h-1.5" />
|
||||
<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>
|
||||
</div>
|
||||
<svg v-if="memLoadHistory.length >= 2" :width="'100%'" :height="32" class="opacity-80" preserveAspectRatio="none" viewBox="0 0 100 32">
|
||||
<path :d="sparklinePath(memLoadHistory, { min: 0, max: 100, w: 100, h: 32 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- GPU -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Gauge class="size-4 text-primary" />GPU
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div v-if="gpuLoad != null">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground">3D 负载</span>
|
||||
<!-- 负载 -->
|
||||
<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>
|
||||
<!-- 普通提权按钮(仅提权 ThingHK,Thing 非管理员且 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(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。
|
||||
普通权限下可读: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(弹 UAC)。ThingHK 崩溃不自动重启,停止走 /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>
|
||||
|
||||
+182
-9
@@ -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,13 +361,29 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
unlistenFns = []
|
||||
}
|
||||
|
||||
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照 */
|
||||
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照。
|
||||
* 首次加载期间 initialized=false,UI 显示 loading 占位(隐藏启动按钮等),
|
||||
* 避免与后端 setup 异步自动启动竞态导致按钮误显示。 */
|
||||
async function init() {
|
||||
await Promise.all([refreshStatus(), refreshKernelInfo()])
|
||||
await subscribe()
|
||||
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
|
||||
if (status.value?.ready) {
|
||||
await fetchSnapshot()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user