监控模块 优化
This commit is contained in:
@@ -132,4 +132,8 @@ internal sealed class ErrorResponse
|
|||||||
[JsonSerializable(typeof(ConfigRequest[]))]
|
[JsonSerializable(typeof(ConfigRequest[]))]
|
||||||
[JsonSerializable(typeof(HealthResponse))]
|
[JsonSerializable(typeof(HealthResponse))]
|
||||||
[JsonSerializable(typeof(ErrorResponse))]
|
[JsonSerializable(typeof(ErrorResponse))]
|
||||||
|
[JsonSerializable(typeof(HardwareConfig))]
|
||||||
|
[JsonSerializable(typeof(HardwareConfigResponse))]
|
||||||
|
[JsonSerializable(typeof(HardwareConfigUpdateResponse))]
|
||||||
|
[JsonSerializable(typeof(HardwareConfigUpdateRequest))]
|
||||||
internal sealed partial class ThingHKJsonContext : JsonSerializerContext;
|
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 readonly Stopwatch _startupSw;
|
||||||
private bool _ready;
|
private bool _ready;
|
||||||
private bool _closed;
|
private bool _closed;
|
||||||
|
private HardwareConfig _config;
|
||||||
|
|
||||||
// 缓存"哪个硬件属于快通道"——按 hardware type 判断更稳定(不同机型传感器命名不一致)
|
// 缓存"哪个硬件属于快通道"——按 hardware type 判断更稳定(不同机型传感器命名不一致)
|
||||||
// 实际分频逻辑见 SamplingScheduler,此处只暴露硬件列表给调度器
|
// 实际分频逻辑见 SamplingScheduler,此处只暴露硬件列表给调度器
|
||||||
@@ -34,24 +35,31 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
public bool IsAdmin => _isAdmin;
|
public bool IsAdmin => _isAdmin;
|
||||||
public double ColdStartMs => _coldStartMs;
|
public double ColdStartMs => _coldStartMs;
|
||||||
public bool Ready => _ready;
|
public bool Ready => _ready;
|
||||||
|
public HardwareConfig Config => _config;
|
||||||
|
|
||||||
public HardwareManager(bool basic)
|
public HardwareManager(HardwareConfig config)
|
||||||
{
|
{
|
||||||
|
_config = config ?? HardwareConfig.Default;
|
||||||
_isAdmin = IsRunningAsAdmin();
|
_isAdmin = IsRunningAsAdmin();
|
||||||
_startupSw = Stopwatch.StartNew();
|
_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
|
_computer = new Computer
|
||||||
{
|
{
|
||||||
IsCpuEnabled = true,
|
IsCpuEnabled = _config.IsHardwareEnabled("cpu"),
|
||||||
IsGpuEnabled = true,
|
IsGpuEnabled = _config.IsHardwareEnabled("gpu"),
|
||||||
IsMemoryEnabled = true,
|
IsMemoryEnabled = _config.IsHardwareEnabled("memory"),
|
||||||
IsStorageEnabled = true,
|
IsStorageEnabled = _config.IsHardwareEnabled("storage"),
|
||||||
// basic 模式只开核心四类硬件(CPU/GPU/RAM/Storage),降低冷启动耗时
|
IsMotherboardEnabled = motherboardEnabled,
|
||||||
IsMotherboardEnabled = !basic,
|
IsControllerEnabled = controllerEnabled,
|
||||||
IsControllerEnabled = !basic,
|
IsBatteryEnabled = _config.IsHardwareEnabled("battery"),
|
||||||
IsBatteryEnabled = !basic,
|
IsNetworkEnabled = _config.IsHardwareEnabled("network"),
|
||||||
IsNetworkEnabled = !basic,
|
IsPsuEnabled = _config.IsHardwareEnabled("psu"),
|
||||||
IsPsuEnabled = !basic,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_computer.Open();
|
_computer.Open();
|
||||||
@@ -68,6 +76,12 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
_ready = true;
|
_ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>热更新传感器类型过滤(无需重启 Kernel,下次 BuildSnapshot 生效)</summary>
|
||||||
|
public void UpdateConfig(HardwareConfig newConfig)
|
||||||
|
{
|
||||||
|
_config = newConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 全量 Update 所有硬件。
|
/// 全量 Update 所有硬件。
|
||||||
/// 由 SamplingScheduler 按通道分频调用。
|
/// 由 SamplingScheduler 按通道分频调用。
|
||||||
@@ -130,11 +144,16 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
|
|
||||||
foreach (var s in hw.Sensors)
|
foreach (var s in hw.Sensors)
|
||||||
{
|
{
|
||||||
|
// 按配置过滤传感器类型:关闭的类型不加入快照,减少传输数据量
|
||||||
|
string sensorType = s.SensorType.ToString().ToLowerInvariant();
|
||||||
|
if (!_config.IsSensorTypeEnabled(sensorType))
|
||||||
|
continue;
|
||||||
|
|
||||||
g.Sensors.Add(new SensorEntry
|
g.Sensors.Add(new SensorEntry
|
||||||
{
|
{
|
||||||
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
|
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
Type = s.SensorType.ToString().ToLowerInvariant(),
|
Type = sensorType,
|
||||||
Value = s.Value,
|
Value = s.Value,
|
||||||
Unit = UnitFor(s.SensorType),
|
Unit = UnitFor(s.SensorType),
|
||||||
HardwareName = hw.Name,
|
HardwareName = hw.Name,
|
||||||
|
|||||||
+190
-4
@@ -10,13 +10,14 @@ namespace ThingHK;
|
|||||||
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
|
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
|
||||||
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
|
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
|
||||||
/// GET /snapshot → 一次性拉取最新缓存快照
|
/// GET /snapshot → 一次性拉取最新缓存快照
|
||||||
/// GET /stream → SSE 订阅,Kernel 主动推送 SensorSnapshot
|
/// GET /stream → SSE 讑阅,Kernel 主动推送 SensorSnapshot
|
||||||
/// POST /config → 运行时调整采样间隔
|
/// POST /config → 运行时调整采样间隔
|
||||||
|
/// POST /shutdown → 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程)
|
||||||
/// GET / → 健康检查(200 OK)
|
/// GET / → 健康检查(200 OK)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class HttpEndpoints
|
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 下排错
|
// 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错
|
||||||
app.Use(async (ctx, next) =>
|
app.Use(async (ctx, next) =>
|
||||||
@@ -122,6 +123,116 @@ internal static class HttpEndpoints
|
|||||||
}, ThingHKJsonContext.Default.ConfigResponse);
|
}, 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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +249,75 @@ internal static class HttpEndpoints
|
|||||||
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
|
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
|
||||||
await ctx.Response.Body.FlushAsync(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>
|
/// <summary>
|
||||||
@@ -149,11 +329,17 @@ internal sealed class KernelHost : IDisposable
|
|||||||
public HardwareManager? Hardware { get; private set; }
|
public HardwareManager? Hardware { get; private set; }
|
||||||
public SamplingScheduler Scheduler { get; private set; } = null!;
|
public SamplingScheduler Scheduler { get; private set; } = null!;
|
||||||
public Stopwatch Uptime { get; } = Stopwatch.StartNew();
|
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 秒)
|
// 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}");
|
Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}");
|
||||||
|
|
||||||
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
|
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
|
||||||
|
|||||||
+48
-16
@@ -27,24 +27,24 @@ internal static class Program
|
|||||||
// 无参数 → 默认 serve(最常见用法)
|
// 无参数 → 默认 serve(最常见用法)
|
||||||
if (args.Length == 0)
|
if (args.Length == 0)
|
||||||
{
|
{
|
||||||
return await ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs);
|
return await ServeAsync(DefaultPort, null, DefaultFastMs, DefaultSlowMs, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve 子命令
|
// serve 子命令
|
||||||
var serveCmd = new Command("serve", "启动 HTTP 服务");
|
var serveCmd = new Command("serve", "启动 HTTP 服务");
|
||||||
var portOpt = new Option<int>("--port", () => DefaultPort, "监听端口");
|
var portOpt = new Option<int>("--port", () => DefaultPort, "监听端口");
|
||||||
var basicOpt = new Option<bool>("--basic", "仅启用核心四类硬件,降低冷启动耗时");
|
var configOpt = new Option<FileInfo?>("--config", "硬件监控配置文件路径(JSON)");
|
||||||
var fullOpt = new Option<bool>("--full", "启用全部硬件(含主板/控制器/网络等)");
|
|
||||||
var fastOpt = new Option<int>("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)");
|
var fastOpt = new Option<int>("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)");
|
||||||
var slowOpt = new Option<int>("--slow-ms", () => DefaultSlowMs, "慢通道采样间隔(毫秒)");
|
var slowOpt = new Option<int>("--slow-ms", () => DefaultSlowMs, "慢通道采样间隔(毫秒)");
|
||||||
|
var logFileOpt = new Option<FileInfo?>("--log-file", "将 stderr 日志写入指定文件(提权模式下控制台隐藏时使用)");
|
||||||
serveCmd.AddOption(portOpt);
|
serveCmd.AddOption(portOpt);
|
||||||
serveCmd.AddOption(basicOpt);
|
serveCmd.AddOption(configOpt);
|
||||||
serveCmd.AddOption(fullOpt);
|
|
||||||
serveCmd.AddOption(fastOpt);
|
serveCmd.AddOption(fastOpt);
|
||||||
serveCmd.AddOption(slowOpt);
|
serveCmd.AddOption(slowOpt);
|
||||||
serveCmd.SetHandler((port, basic, full, fast, slow) =>
|
serveCmd.AddOption(logFileOpt);
|
||||||
ServeAsync(port, basic || !full, fast, slow),
|
serveCmd.SetHandler((port, config, fast, slow, logFile) =>
|
||||||
portOpt, basicOpt, fullOpt, fastOpt, slowOpt);
|
ServeAsync(port, config?.FullName, fast, slow, logFile),
|
||||||
|
portOpt, configOpt, fastOpt, slowOpt, logFileOpt);
|
||||||
|
|
||||||
// scan 子命令(阶段一覆盖矩阵测试,保留)
|
// scan 子命令(阶段一覆盖矩阵测试,保留)
|
||||||
var scanCmd = new Command("scan", "扫描一次并输出覆盖矩阵(阶段一验证用)");
|
var scanCmd = new Command("scan", "扫描一次并输出覆盖矩阵(阶段一验证用)");
|
||||||
@@ -58,17 +58,37 @@ internal static class Program
|
|||||||
root.Add(serveCmd);
|
root.Add(serveCmd);
|
||||||
root.Add(scanCmd);
|
root.Add(scanCmd);
|
||||||
// 兼容阶段一的旧用法:ThingHK.exe --json --basic
|
// 兼容阶段一的旧用法: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);
|
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();
|
using var kernel = new KernelHost();
|
||||||
await kernel.StartAsync(basic, fastMs, slowMs);
|
await kernel.StartAsync(configPath, fastMs, slowMs);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder();
|
var builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseUrls($"http://127.0.0.1:{port}/");
|
builder.WebHost.UseUrls($"http://127.0.0.1:{port}/");
|
||||||
@@ -78,15 +98,16 @@ internal static class Program
|
|||||||
builder.Logging.AddProvider(new StderrLoggerProvider());
|
builder.Logging.AddProvider(new StderrLoggerProvider());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.MapThingHKEndpoints(kernel);
|
|
||||||
|
|
||||||
// Ctrl+C 优雅关闭
|
// Ctrl+C 优雅关闭(cts 同时用于 /shutdown 接口触发退出)
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => 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] 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
|
try
|
||||||
{
|
{
|
||||||
@@ -103,7 +124,18 @@ internal static class Program
|
|||||||
private static async Task<int> ScanAsync(bool json, bool basic)
|
private static async Task<int> ScanAsync(bool json, bool basic)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[ThingHK] scan 模式: json={json} basic={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:部分传感器首轮才初始化
|
// 第二轮 Update:部分传感器首轮才初始化
|
||||||
hw.UpdateAll();
|
hw.UpdateAll();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程
|
|||||||
| `/snapshot` | GET | 一次性拉取最新缓存快照 |
|
| `/snapshot` | GET | 一次性拉取最新缓存快照 |
|
||||||
| `/stream` | GET (SSE) | 订阅推送,Kernel 按采样间隔主动 Push `SensorSnapshot` |
|
| `/stream` | GET (SSE) | 订阅推送,Kernel 按采样间隔主动 Push `SensorSnapshot` |
|
||||||
| `/config` | POST | 运行时调整采样间隔(`{"FastIntervalMs":500,"SlowIntervalMs":3000}`) |
|
| `/config` | POST | 运行时调整采样间隔(`{"FastIntervalMs":500,"SlowIntervalMs":3000}`) |
|
||||||
|
| `/shutdown` | POST | 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程) |
|
||||||
|
|
||||||
### 3.2 数据契约(显式版本化)
|
### 3.2 数据契约(显式版本化)
|
||||||
|
|
||||||
@@ -85,14 +86,15 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程
|
|||||||
|
|
||||||
### 3.3 Tauri 命令与事件
|
### 3.3 Tauri 命令与事件
|
||||||
|
|
||||||
**6 个命令**:
|
**7 个命令**:
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `monitor_kernel_info` | 查询 Kernel 二进制路径/是否存在/端口 |
|
| `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_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_status` | 透传 Kernel `/status` |
|
||||||
| `monitor_get_snapshot` | 透传 Kernel `/snapshot` |
|
| `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` |
|
| [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) |
|
| [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`(递归收集) |
|
| [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 |
|
| [rd.xml](file:///d:/Atie/Gitea/Thing/ThingHK/rd.xml) | LHB 反射根描述符,`preserve="all"` 兜底 AOT trimming |
|
||||||
|
|
||||||
### Rust 集成
|
### Rust 集成
|
||||||
|
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) | Tauri 侧 Kernel 客户端:复制二进制、构造 `StartProcessParams`、轮询就绪、SSE 订阅、写入熔断 + 自动重连 |
|
| [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) | 6 个 monitor 命令注册 + setup 自动启动 |
|
| [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) |
|
| [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
|
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 模块共享)。
|
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 / 笔记本场景的实际传感器覆盖度验证(需实际硬件)。
|
3. **多机型回归**:AMD Ryzen / NVIDIA GPU / 笔记本场景的实际传感器覆盖度验证(需实际硬件)。
|
||||||
4. **Kernel 版本管理**:`prepare_kernel` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。
|
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",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -1043,6 +1062,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.11"
|
version = "3.0.11"
|
||||||
@@ -2394,6 +2419,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3021,6 +3055,26 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -3824,6 +3878,20 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3892,7 +3960,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -3963,7 +4031,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4118,7 +4186,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4160,7 +4228,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4185,7 +4253,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4272,6 +4340,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
@@ -5023,10 +5092,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5047,7 +5116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5097,6 +5166,16 @@ dependencies = [
|
|||||||
"windows-version",
|
"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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -5119,14 +5198,26 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.3.4",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.4.2",
|
||||||
@@ -5138,8 +5229,8 @@ version = "0.62.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.4.1",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
@@ -5156,6 +5247,17 @@ dependencies = [
|
|||||||
"windows-threading",
|
"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]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -5167,6 +5269,17 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.59.3"
|
version = "0.59.3"
|
||||||
@@ -5211,6 +5324,15 @@ dependencies = [
|
|||||||
"windows-strings 0.5.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5665,7 +5787,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ axum = "0.7"
|
|||||||
url = "2"
|
url = "2"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
sysinfo = "0.32"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.52"
|
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]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
|
|||||||
Binary file not shown.
+34
-1
@@ -1,9 +1,11 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
mod download_engine;
|
mod download_engine;
|
||||||
mod logger;
|
mod logger;
|
||||||
mod mihomo_manager;
|
mod mihomo_manager;
|
||||||
mod monitor_kernel;
|
mod monitor_kernel;
|
||||||
|
mod network_monitor;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
|
|
||||||
use download_engine::{
|
use download_engine::{
|
||||||
@@ -23,9 +25,12 @@ use mihomo_manager::{
|
|||||||
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager,
|
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager,
|
||||||
};
|
};
|
||||||
use monitor_kernel::{
|
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,
|
monitor_stop, MonitorKernel,
|
||||||
};
|
};
|
||||||
|
use network_monitor::{network_monitor_status, NetworkMonitor};
|
||||||
use process_manager::{
|
use process_manager::{
|
||||||
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
||||||
stop_all_processes, stop_process, ProcessManager,
|
stop_all_processes, stop_process, ProcessManager,
|
||||||
@@ -41,12 +46,15 @@ fn quit_app(
|
|||||||
state: tauri::State<'_, ProcessManager>,
|
state: tauri::State<'_, ProcessManager>,
|
||||||
mihomo: tauri::State<'_, MihomoManager>,
|
mihomo: tauri::State<'_, MihomoManager>,
|
||||||
engine: tauri::State<'_, DownloadEngine>,
|
engine: tauri::State<'_, DownloadEngine>,
|
||||||
|
monitor: tauri::State<'_, MonitorKernel>,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
) {
|
) {
|
||||||
// 退出前清理系统代理,避免遗留导致网络问题
|
// 退出前清理系统代理,避免遗留导致网络问题
|
||||||
mihomo.cleanup_on_exit();
|
mihomo.cleanup_on_exit();
|
||||||
// 退出前保存下载引擎状态
|
// 退出前保存下载引擎状态
|
||||||
engine.cleanup_on_exit();
|
engine.cleanup_on_exit();
|
||||||
|
// 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown)
|
||||||
|
tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app));
|
||||||
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
|
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
|
||||||
state.stop_all();
|
state.stop_all();
|
||||||
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
|
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
|
||||||
@@ -104,9 +112,16 @@ pub fn run() {
|
|||||||
monitor_kernel_info,
|
monitor_kernel_info,
|
||||||
monitor_status,
|
monitor_status,
|
||||||
monitor_start,
|
monitor_start,
|
||||||
|
monitor_start_elevated,
|
||||||
|
monitor_elevate_self,
|
||||||
monitor_stop,
|
monitor_stop,
|
||||||
monitor_get_status,
|
monitor_get_status,
|
||||||
monitor_get_snapshot,
|
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_get_tasks,
|
||||||
downloader_add_task,
|
downloader_add_task,
|
||||||
downloader_pause_task,
|
downloader_pause_task,
|
||||||
@@ -134,6 +149,12 @@ pub fn run() {
|
|||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from("."));
|
.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());
|
let mihomo = MihomoManager::new(app_data_dir.clone());
|
||||||
app.manage(mihomo);
|
app.manage(mihomo);
|
||||||
|
|
||||||
@@ -141,6 +162,12 @@ pub fn run() {
|
|||||||
let monitor = MonitorKernel::new(app_data_dir.clone());
|
let monitor = MonitorKernel::new(app_data_dir.clone());
|
||||||
app.manage(monitor);
|
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/
|
// 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/
|
||||||
let engine = DownloadEngine::new(
|
let engine = DownloadEngine::new(
|
||||||
app_data_dir.join("downloader"),
|
app_data_dir.join("downloader"),
|
||||||
@@ -179,6 +206,9 @@ pub fn run() {
|
|||||||
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
||||||
engine.cleanup_on_exit();
|
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>() {
|
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||||
pm.stop_all();
|
pm.stop_all();
|
||||||
}
|
}
|
||||||
@@ -241,6 +271,9 @@ pub fn run() {
|
|||||||
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
||||||
engine.cleanup_on_exit();
|
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>() {
|
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||||
pm.stop_all();
|
pm.stop_all();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -35,6 +36,155 @@ const READY_POLL_INTERVAL_MS: u64 = 500;
|
|||||||
/// 冷启动就绪总超时(阶段一实测冷启动约 5s,留 5s 余量)
|
/// 冷启动就绪总超时(阶段一实测冷启动约 5s,留 5s 余量)
|
||||||
const READY_TIMEOUT_MS: u64 = 10_000;
|
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 对应)
|
/// Kernel /status 响应(与 ThingHK Contracts.cs KernelStatus 对应)
|
||||||
@@ -73,6 +223,10 @@ pub struct MonitorStatus {
|
|||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
pub sensor_count: u32,
|
pub sensor_count: u32,
|
||||||
pub restart_count: u32,
|
pub restart_count: u32,
|
||||||
|
/// 是否为提权模式(通过 UAC 以管理员权限启动)
|
||||||
|
pub elevated: bool,
|
||||||
|
/// Thing 自身是否以管理员权限运行(永久提权模式)
|
||||||
|
pub thing_elevated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== MonitorKernel =====================
|
// ===================== MonitorKernel =====================
|
||||||
@@ -91,6 +245,8 @@ pub struct MonitorKernel {
|
|||||||
sub_handle: Arc<Mutex<Option<tauri::async_runtime::JoinHandle<()>>>>,
|
sub_handle: Arc<Mutex<Option<tauri::async_runtime::JoinHandle<()>>>>,
|
||||||
/// 监听 process-status-changed 的句柄,用于在 stop 时取消监听
|
/// 监听 process-status-changed 的句柄,用于在 stop 时取消监听
|
||||||
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
|
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
|
||||||
|
/// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown)
|
||||||
|
elevated: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MonitorKernel {
|
impl MonitorKernel {
|
||||||
@@ -108,9 +264,25 @@ impl MonitorKernel {
|
|||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
sub_handle: Arc::new(Mutex::new(None)),
|
sub_handle: Arc::new(Mutex::new(None)),
|
||||||
listener_ids: Arc::new(Mutex::new(Vec::new())),
|
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 {
|
fn cores_dir(&self) -> PathBuf {
|
||||||
self.root.join("cores")
|
self.root.join("cores")
|
||||||
}
|
}
|
||||||
@@ -120,15 +292,23 @@ impl MonitorKernel {
|
|||||||
fn kernel_url(&self) -> String {
|
fn kernel_url(&self) -> String {
|
||||||
format!("http://127.0.0.1:{}", KERNEL_PORT)
|
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> {
|
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
||||||
let kernel = self.kernel_path();
|
let kernel = self.kernel_path();
|
||||||
if !kernel.exists() {
|
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
||||||
if let Ok(res) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
if src.exists() {
|
||||||
if res.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::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(),
|
"serve".into(),
|
||||||
"--port".into(),
|
"--port".into(),
|
||||||
KERNEL_PORT.to_string(),
|
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()),
|
cwd: Some(self.cores_dir().to_string_lossy().to_string()),
|
||||||
name: "ThingHK".into(),
|
name: "ThingHK".into(),
|
||||||
@@ -273,6 +454,12 @@ impl MonitorKernel {
|
|||||||
// 重连前先确认 Kernel 是否还活着(可能已被 stop)
|
// 重连前先确认 Kernel 是否还活着(可能已被 stop)
|
||||||
if !self.is_kernel_alive().await {
|
if !self.is_kernel_alive().await {
|
||||||
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环");
|
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环");
|
||||||
|
// 提权模式下 Kernel 崩溃/退出后重置 elevated 标志,
|
||||||
|
// 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启
|
||||||
|
if self.is_elevated() {
|
||||||
|
self.elevated.store(false, Ordering::SeqCst);
|
||||||
|
eprintln!("[monitor] 提权 Kernel 已退出,重置 elevated 标志");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,7 +532,14 @@ impl MonitorKernel {
|
|||||||
|
|
||||||
/// 注册 process-status-changed 监听:当 Kernel 进程被自动重启恢复 Running 时,
|
/// 注册 process-status-changed 监听:当 Kernel 进程被自动重启恢复 Running 时,
|
||||||
/// 自动重新启动 SSE 订阅(实现崩溃恢复后的自愈)。
|
/// 自动重新启动 SSE 订阅(实现崩溃恢复后的自愈)。
|
||||||
|
/// 注册前先清理旧 listener,防止重复注册导致多次 spawn SSE 订阅。
|
||||||
pub async fn register_auto_reconnect(self: Self, app: AppHandle) {
|
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 app_clone = app.clone();
|
||||||
let this = self.clone();
|
let this = self.clone();
|
||||||
let id = app.listen("process-status-changed", move |event| {
|
let id = app.listen("process-status-changed", move |event| {
|
||||||
@@ -371,14 +565,67 @@ impl MonitorKernel {
|
|||||||
|
|
||||||
/// 启动 Kernel 进程并开始 SSE 订阅(命令和 setup 自动启动共用)。
|
/// 启动 Kernel 进程并开始 SSE 订阅(命令和 setup 自动启动共用)。
|
||||||
/// ProcessManager 通过 app.state 获取,无需外部传入。
|
/// 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(
|
pub async fn start_with_subscription(
|
||||||
&self,
|
&self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
) -> Result<crate::process_manager::ProcessInfo, String> {
|
) -> 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 pm = app.state::<ProcessManager>();
|
||||||
let params = self.prepare_for_start(app)?;
|
let params = self.prepare_for_start(app)?;
|
||||||
let info = pm.start(params)?;
|
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> 状态)启动订阅,
|
// 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅,
|
||||||
// 确保 register_auto_reconnect 注册的 listener 与 sub_handle 共享,
|
// 确保 register_auto_reconnect 注册的 listener 与 sub_handle 共享,
|
||||||
// stop_subscription 时才能正确清理 listener。
|
// stop_subscription 时才能正确清理 listener。
|
||||||
@@ -392,6 +639,117 @@ impl MonitorKernel {
|
|||||||
|
|
||||||
Ok(info)
|
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 内容。
|
/// 解析 SSE 事件文本,提取 data: 字段的 JSON 内容。
|
||||||
@@ -425,12 +783,29 @@ pub async fn monitor_status(
|
|||||||
state: tauri::State<'_, MonitorKernel>,
|
state: tauri::State<'_, MonitorKernel>,
|
||||||
pm: tauri::State<'_, ProcessManager>,
|
pm: tauri::State<'_, ProcessManager>,
|
||||||
) -> Result<MonitorStatus, String> {
|
) -> 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 info = pm.get_status(PROCESS_ID);
|
||||||
let running = info
|
let running = info
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|i| matches!(i.status, crate::process_manager::ProcessStatus::Running))
|
.map(|i| matches!(i.status, crate::process_manager::ProcessStatus::Running))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
// 只在运行时查询 Kernel /status(避免未运行时发起无意义的 HTTP 请求)
|
|
||||||
let kernel_status = if running { state.get_status().await.ok() } else { None };
|
let kernel_status = if running { state.get_status().await.ok() } else { None };
|
||||||
Ok(MonitorStatus {
|
Ok(MonitorStatus {
|
||||||
running,
|
running,
|
||||||
@@ -438,6 +813,9 @@ pub async fn monitor_status(
|
|||||||
ready: kernel_status.as_ref().map(|s| s.ready).unwrap_or(false),
|
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),
|
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),
|
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
|
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]
|
#[tauri::command]
|
||||||
pub async fn monitor_stop(
|
pub async fn monitor_stop(
|
||||||
state: tauri::State<'_, MonitorKernel>,
|
state: tauri::State<'_, MonitorKernel>,
|
||||||
@@ -456,8 +878,16 @@ pub async fn monitor_stop(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.stop_subscription(&app).await;
|
state.stop_subscription(&app).await;
|
||||||
|
// 仅"提权 ThingHK"模式(Thing 普通、ThingHK 管理员)需要 /shutdown
|
||||||
|
// 永久提权模式(Thing 管理员)和普通模式都用 ProcessManager kill
|
||||||
|
if state.is_elevated() && !is_thing_elevated() {
|
||||||
|
state.shutdown_kernel().await?;
|
||||||
|
state.elevated.store(false, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
pm.stop(PROCESS_ID)
|
pm.stop(PROCESS_ID)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Result<KernelStatus, String> {
|
pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Result<KernelStatus, String> {
|
||||||
@@ -468,3 +898,68 @@ pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Resul
|
|||||||
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
|
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
|
||||||
state.get_snapshot().await
|
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 {
|
import {
|
||||||
Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick,
|
Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick,
|
||||||
Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle,
|
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'
|
} from '@lucide/vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
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 { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
|
||||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Separator } from '@/components/ui/separator'
|
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()
|
const store = useMonitorStore()
|
||||||
|
|
||||||
@@ -31,9 +37,9 @@ const tabsListRef = useModuleTabs(activeTab, [
|
|||||||
// 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次)
|
// 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次)
|
||||||
const HISTORY_LEN = 30
|
const HISTORY_LEN = 30
|
||||||
const cpuTempHistory = ref<number[]>([])
|
const cpuTempHistory = ref<number[]>([])
|
||||||
const cpuLoadHistory = ref<number[]>([])
|
const gpuTempHistory = ref<number[]>([])
|
||||||
const memLoadHistory = ref<number[]>([])
|
const netDownHistory = ref<number[]>([])
|
||||||
const gpuLoadHistory = ref<number[]>([])
|
const netUpHistory = ref<number[]>([])
|
||||||
|
|
||||||
function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
|
function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
|
||||||
if (v == null || !isFinite(v)) return
|
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()
|
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 =====
|
// ===== 关键指标 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 cpuTemp = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
|
||||||
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
|
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 memLoad = computed(() => store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' }))
|
||||||
const gpuLoad = computed(() =>
|
const memUsedGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Used', type: 'data' }))
|
||||||
store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ??
|
const memAvailGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Available', type: 'data' }))
|
||||||
store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ??
|
const memTotalGB = computed(() => {
|
||||||
store.findSensorValue('gpuintel', { type: 'load' }),
|
const used = memUsedGB.value
|
||||||
)
|
const avail = memAvailGB.value
|
||||||
const storageTemp = computed(() => store.findSensorValue('storage', { type: 'temperature' }))
|
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'
|
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> = {
|
const typeLabels: Record<string, string> = {
|
||||||
temperature: '温度',
|
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()
|
store.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,105 +483,42 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
<!-- ===== 概览 ===== -->
|
<!-- ===== 概览 ===== -->
|
||||||
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<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">
|
<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" />
|
<Loader2 class="size-8 animate-spin" />
|
||||||
<p class="text-sm">正在加载...</p>
|
<p class="text-sm">正在加载...</p>
|
||||||
</div>
|
</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" />
|
<div v-else key="content" class="grid grid-cols-1 md:grid-cols-3 gap-2.5">
|
||||||
<p class="text-sm">Kernel 未启动</p>
|
<!-- CPU(温度 + 功耗 + 曲线图,未读数据以 -- 占位) -->
|
||||||
<Button size="sm" :disabled="store.starting" @click="handleStart">
|
<Card class="py-0 gap-0">
|
||||||
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
|
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
||||||
<Play v-else class="size-3.5" />启动 Kernel
|
<CardTitle class="flex items-center justify-between text-sm">
|
||||||
</Button>
|
<span class="flex items-center gap-1.5"><Cpu class="size-4 text-primary" />CPU</span>
|
||||||
</div>
|
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="cpuModel ?? ''">{{ cpuModel ?? '--' }}</span>
|
||||||
|
|
||||||
<div v-else key="content" class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
|
|
||||||
<!-- 状态卡片 -->
|
|
||||||
<Card>
|
|
||||||
<CardHeader class="pb-3">
|
|
||||||
<CardTitle class="flex items-center justify-between text-base">
|
|
||||||
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />Kernel 状态</span>
|
|
||||||
<span :class="['text-xs px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
|
|
||||||
{{ stateMeta[store.connState].text }}
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2 text-sm">
|
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
|
||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
<!-- 温度 + 功耗 + 曲线图 -->
|
||||||
<div class="flex justify-between">
|
<div class="flex items-end justify-between gap-2">
|
||||||
<span class="text-muted-foreground">进程</span>
|
|
||||||
<span>{{ store.status?.running ? '运行中' : '已停止' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">PID</span>
|
|
||||||
<span class="font-mono">{{ store.status?.pid ?? '--' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">就绪</span>
|
|
||||||
<span>{{ store.status?.ready ? '是' : '否' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">传感器数</span>
|
|
||||||
<span>{{ store.status?.sensorCount ?? '--' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">重启次数</span>
|
|
||||||
<span>{{ store.status?.restartCount ?? 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">事件数</span>
|
|
||||||
<span>{{ store.eventCount }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<component :is="store.snapshot?.isAdmin ? ShieldCheck : ShieldAlert" class="size-3.5" />
|
|
||||||
权限
|
|
||||||
</span>
|
|
||||||
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
|
|
||||||
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div v-if="!store.snapshot?.isAdmin" class="text-xs text-orange-600 dark:text-orange-400 flex items-start gap-1.5 pt-1">
|
|
||||||
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
|
|
||||||
<span>非管理员运行,部分传感器(电压/主板/SMBus)可能不可读。可在设置页查看降级说明。</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 pt-1">
|
|
||||||
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping" @click="handleRefresh">
|
|
||||||
<RefreshCw class="size-3" />刷新
|
|
||||||
</Button>
|
|
||||||
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping" @click="handleStop">
|
|
||||||
<Square class="size-3" />停止
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- CPU 关键指标 -->
|
|
||||||
<Card>
|
|
||||||
<CardHeader class="pb-3">
|
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
|
||||||
<Cpu class="size-4 text-primary" />CPU
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-3">
|
|
||||||
<div class="flex items-end justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />封装温度</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>
|
{{ fmt(cpuTemp, 0) }}<span class="text-sm font-normal">°C</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg v-if="cpuTempHistory.length >= 2" :width="100" :height="28" class="opacity-80">
|
<div class="text-right">
|
||||||
<path :d="sparklinePath(cpuTempHistory)" :stroke="'currentColor'" :class="tempColor(cpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 负载 -->
|
||||||
<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="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>
|
<span :class="['font-medium tabular-nums', loadColor(cpuLoad)]">{{ fmt(cpuLoad, 0) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,64 +527,203 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 内存 -->
|
<!-- GPU(结构与 CPU 一致:温度 + 功耗 + 曲线图,未读数据以 -- 占位) -->
|
||||||
<Card>
|
<Card class="py-0 gap-0">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center justify-between text-sm">
|
||||||
<MemoryStick class="size-4 text-primary" />内存
|
<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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
|
||||||
|
<!-- 温度 + 功耗 + 曲线图(与 CPU 卡片结构一致) -->
|
||||||
|
<div class="flex items-end justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between text-xs mb-1">
|
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />核心温度</div>
|
||||||
<span class="text-muted-foreground">内存负载</span>
|
<div :class="['text-2xl font-bold tabular-nums leading-tight', tempColor(gpuTemp)]">
|
||||||
<span :class="['font-medium tabular-nums', loadColor(memLoad)]">{{ fmt(memLoad, 0) }}%</span>
|
{{ fmt(gpuTemp, 0) }}<span class="text-sm font-normal">°C</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress :model-value="memLoad ?? 0" class="h-1.5" />
|
|
||||||
</div>
|
</div>
|
||||||
<svg v-if="memLoadHistory.length >= 2" :width="'100%'" :height="32" class="opacity-80" preserveAspectRatio="none" viewBox="0 0 100 32">
|
<div class="text-right">
|
||||||
<path :d="sparklinePath(memLoadHistory, { min: 0, max: 100, w: 100, h: 32 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
<div class="text-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>
|
</svg>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<!-- 负载 -->
|
||||||
|
<div>
|
||||||
<!-- GPU -->
|
<div class="flex items-center justify-between text-xs mb-0.5">
|
||||||
<Card>
|
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />3D 负载</span>
|
||||||
<CardHeader class="pb-3">
|
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
|
||||||
<Gauge class="size-4 text-primary" />GPU
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-3">
|
|
||||||
<div v-if="gpuLoad != null">
|
|
||||||
<div class="flex items-center justify-between text-xs mb-1">
|
|
||||||
<span class="text-muted-foreground">3D 负载</span>
|
|
||||||
<span :class="['font-medium tabular-nums', loadColor(gpuLoad)]">{{ fmt(gpuLoad, 0) }}%</span>
|
<span :class="['font-medium tabular-nums', loadColor(gpuLoad)]">{{ fmt(gpuLoad, 0) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress :model-value="gpuLoad ?? 0" class="h-1.5" />
|
<Progress :model-value="gpuLoad ?? 0" class="h-1.5" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-muted-foreground py-2 text-center">暂无 GPU 数据</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 存储 -->
|
<!-- 内存(主指标大字 + 进度条,未读数据以 -- 占位) -->
|
||||||
<Card v-if="storageTemp != null">
|
<Card class="py-0 gap-0">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<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" />存储
|
<HardDrive class="size-4 text-primary" />存储
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="px-3.5 pb-2.5">
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />温度</div>
|
<div v-if="storageDrives.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
<div :class="['text-2xl font-bold tabular-nums', tempColor(storageTemp)]">
|
<div v-for="drive in storageDrives" :key="drive.name" class="border rounded-md p-2 space-y-1">
|
||||||
{{ fmt(storageTemp, 0) }}<span class="text-sm font-normal">°C</span>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 断线提示 -->
|
<!-- 断线提示 -->
|
||||||
<Card v-if="store.connState === 'disconnected'" class="border-orange-500/40">
|
<Card v-if="store.connState === 'disconnected'" class="md:col-span-3 border-orange-500/40 py-0 gap-0">
|
||||||
<CardContent class="pt-4 flex items-start gap-2 text-sm">
|
<CardContent class="pt-3 flex items-start gap-2 text-sm">
|
||||||
<AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" />
|
<AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium text-orange-600 dark:text-orange-400">SSE 连接断开</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>
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
<Card v-if="store.errorMsg" class="border-red-500/40">
|
<Card v-if="store.errorMsg" class="md:col-span-3 border-red-500/40 py-0 gap-0">
|
||||||
<CardContent class="pt-4 flex items-start gap-2 text-sm">
|
<CardContent class="pt-3 flex items-start gap-2 text-sm">
|
||||||
<AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" />
|
<AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium text-red-600 dark:text-red-400">错误</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">
|
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="handleRefresh">
|
||||||
<RefreshCw class="size-3.5" />刷新状态
|
<RefreshCw class="size-3.5" />刷新状态
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="openConfigDialog">
|
||||||
|
<ListChecks class="size-3.5" />监控项
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
Kernel 由 ProcessManager 统一管理,应用启动时自动拉起,崩溃自动重启。
|
Kernel 由 ProcessManager 统一管理,应用启动时自动拉起,崩溃自动重启。
|
||||||
@@ -538,9 +855,38 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
{{ store.kernelInfo.exists ? '是' : '否' }}
|
{{ store.kernelInfo.exists ? '是' : '否' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -552,16 +898,48 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
<ShieldCheck class="size-4 text-primary" />权限与降级
|
<ShieldCheck class="size-4 text-primary" />权限与降级
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
<CardContent class="space-y-3 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>
|
<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 />
|
<Separator />
|
||||||
<p class="text-xs leading-relaxed">
|
<p class="text-xs leading-relaxed">
|
||||||
LibreHardwareMonitor 访问 SMBus、部分 EC 传感器、某些 GPU 传感器需要管理员权限。
|
LibreHardwareMonitor 访问 CPU MSR(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。
|
||||||
默认非提权运行,覆盖大部分 CPU/GPU 温度(通过 OHM RPC/WMI 仍可读),牺牲部分主板/电压传感器。
|
普通权限下可读:CPU 负载/功率、内存、GPU 负载。不可读:CPU 温度/时钟、存储、主板/电压。
|
||||||
</p>
|
</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" />
|
<div class="space-y-2">
|
||||||
<span>提权策略(任务计划程序免 UAC 启动)将在阶段五实现,当前以普通权限运行。</span>
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -590,4 +968,76 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|||||||
+178
-5
@@ -42,6 +42,10 @@ export interface MonitorStatus {
|
|||||||
ready: boolean
|
ready: boolean
|
||||||
sensorCount: number
|
sensorCount: number
|
||||||
restartCount: number
|
restartCount: number
|
||||||
|
/** 是否为提权模式(通过 UAC 以管理员权限启动) */
|
||||||
|
elevated: boolean
|
||||||
|
/** Thing 自身是否以管理员权限运行(永久提权模式) */
|
||||||
|
thingElevated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitorKernelInfo {
|
export interface MonitorKernelInfo {
|
||||||
@@ -50,6 +54,48 @@ export interface MonitorKernelInfo {
|
|||||||
port: number
|
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'
|
export type ConnectionState = 'idle' | 'loading' | 'connected' | 'disconnected' | 'error'
|
||||||
|
|
||||||
@@ -70,9 +116,18 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
const starting = ref(false)
|
const starting = ref(false)
|
||||||
const stopping = ref(false)
|
const stopping = ref(false)
|
||||||
|
|
||||||
|
/** 网速数据(由 network_monitor 后台任务推送,独立于 Kernel) */
|
||||||
|
const networkSpeed = ref<NetworkSpeed | null>(null)
|
||||||
|
|
||||||
/** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */
|
/** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
|
|
||||||
|
/** 永久提权标志是否已启用(后续启动自动触发 UAC) */
|
||||||
|
const elevateOnLaunch = ref(false)
|
||||||
|
|
||||||
|
/** 硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */
|
||||||
|
const hardwareConfig = ref<HardwareConfigResponse | null>(null)
|
||||||
|
|
||||||
let unlistenFns: UnlistenFn[] = []
|
let unlistenFns: UnlistenFn[] = []
|
||||||
|
|
||||||
// ===== getters =====
|
// ===== getters =====
|
||||||
@@ -122,8 +177,6 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('获取状态失败: ' + e)
|
logger.error('获取状态失败: ' + e)
|
||||||
} finally {
|
|
||||||
initialized.value = true
|
|
||||||
}
|
}
|
||||||
return status.value
|
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) */
|
/** 主动拉取一次性快照(切回 tab 时立即填充,不等下一个 SSE tick) */
|
||||||
async function fetchSnapshot() {
|
async function fetchSnapshot() {
|
||||||
try {
|
try {
|
||||||
@@ -196,7 +339,8 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
refreshStatus()
|
refreshStatus()
|
||||||
}))
|
}))
|
||||||
unlistenFns.push(await listen('monitor-loading', () => {
|
unlistenFns.push(await listen('monitor-loading', () => {
|
||||||
// 状态由 status 轮询反映
|
// 后端正在等待 Kernel ready,刷新状态以反映 running=true
|
||||||
|
refreshStatus()
|
||||||
}))
|
}))
|
||||||
unlistenFns.push(await listen('monitor-disconnected', () => {
|
unlistenFns.push(await listen('monitor-disconnected', () => {
|
||||||
logger.warn('SSE 断开,等待自动重连')
|
logger.warn('SSE 断开,等待自动重连')
|
||||||
@@ -206,6 +350,10 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
errorMsg.value = e.payload?.message ?? 'Kernel 错误'
|
errorMsg.value = e.payload?.message ?? 'Kernel 错误'
|
||||||
logger.error('Kernel 错误: ' + JSON.stringify(e.payload))
|
logger.error('Kernel 错误: ' + JSON.stringify(e.payload))
|
||||||
}))
|
}))
|
||||||
|
// 网速监控事件(独立于 Kernel,应用启动即推送)
|
||||||
|
unlistenFns.push(await listen<NetworkSpeed>('monitor-network', (e) => {
|
||||||
|
networkSpeed.value = e.payload
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsubscribe() {
|
function unsubscribe() {
|
||||||
@@ -213,14 +361,30 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
unlistenFns = []
|
unlistenFns = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照 */
|
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照。
|
||||||
|
* 首次加载期间 initialized=false,UI 显示 loading 占位(隐藏启动按钮等),
|
||||||
|
* 避免与后端 setup 异步自动启动竞态导致按钮误显示。 */
|
||||||
async function init() {
|
async function init() {
|
||||||
await Promise.all([refreshStatus(), refreshKernelInfo()])
|
try {
|
||||||
|
await Promise.all([refreshStatus(), refreshKernelInfo(), refreshElevateOnLaunch()])
|
||||||
await subscribe()
|
await subscribe()
|
||||||
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
|
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
|
||||||
if (status.value?.ready) {
|
if (status.value?.ready) {
|
||||||
await fetchSnapshot()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模块卸载时调用:仅取消事件订阅,不停止 Kernel(Kernel 由 ProcessManager 全局管理) */
|
/** 模块卸载时调用:仅取消事件订阅,不停止 Kernel(Kernel 由 ProcessManager 全局管理) */
|
||||||
@@ -239,6 +403,9 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
starting,
|
starting,
|
||||||
stopping,
|
stopping,
|
||||||
initialized,
|
initialized,
|
||||||
|
networkSpeed,
|
||||||
|
elevateOnLaunch,
|
||||||
|
hardwareConfig,
|
||||||
// getters
|
// getters
|
||||||
connState,
|
connState,
|
||||||
isLive,
|
isLive,
|
||||||
@@ -247,7 +414,13 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
findSensorValue,
|
findSensorValue,
|
||||||
refreshStatus,
|
refreshStatus,
|
||||||
refreshKernelInfo,
|
refreshKernelInfo,
|
||||||
|
refreshElevateOnLaunch,
|
||||||
start,
|
start,
|
||||||
|
startElevated,
|
||||||
|
elevateSelf,
|
||||||
|
cancelElevation,
|
||||||
|
fetchHardwareConfig,
|
||||||
|
saveHardwareConfig,
|
||||||
stop,
|
stop,
|
||||||
fetchSnapshot,
|
fetchSnapshot,
|
||||||
init,
|
init,
|
||||||
|
|||||||
Reference in New Issue
Block a user