Files
Thing/ThingHK/HttpEndpoints.cs
T
2026-07-24 18:16:34 +08:00

358 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ThingHK;
/// <summary>
/// HTTP 服务层:minimal API 风格扩展方法。
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
/// GET /snapshot → 一次性拉取最新缓存快照
/// GET /stream → SSE 讑阅,Kernel 主动推送 SensorSnapshot
/// POST /config → 运行时调整采样间隔
/// POST /shutdown → 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程)
/// GET / → 健康检查(200 OK)
/// </summary>
internal static class HttpEndpoints
{
public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel, CancellationTokenSource shutdownCts)
{
// 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错
app.Use(async (ctx, next) =>
{
try { await next(); }
catch (Exception ex)
{
Console.Error.WriteLine($"[HTTP] {ctx.Request.Method} {ctx.Request.Path} 异常: {ex}");
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync($"{{\"error\":\"internal\",\"message\":\"{ex.Message.Replace("\"", "\\\"")}\"}}");
}
});
// 根路由:进程存活检查(curl 友好)
// AOT 下必须用 Results.Json + TypeInfoResults.Ok(object) 会走默认 options 抛 JsonTypeInfo 异常
app.MapGet("/", () => Results.Json(new HealthResponse(), ThingHKJsonContext.Default.HealthResponse));
// 冷启动就绪探测
app.MapGet("/status", () =>
{
var hw = kernel.Hardware;
var snap = kernel.Scheduler.Cache.GetLatest();
var status = new KernelStatus
{
Ready = hw?.Ready ?? false,
IsAdmin = hw?.IsAdmin ?? false,
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
GroupCount = snap?.Groups.Count ?? 0,
SensorCount = kernel.Scheduler.Cache.SensorCount,
Providers = snap?.Groups.Select(g => g.Id).ToList() ?? new List<string>(),
};
return Results.Json(status, ThingHKJsonContext.Default.KernelStatus);
});
// 一次性快照
app.MapGet("/snapshot", (HttpContext ctx) =>
{
var snap = kernel.Scheduler.Cache.GetLatest();
if (snap == null)
{
ctx.Response.StatusCode = 404;
return Results.Json(new ErrorResponse("not_ready", "Kernel 尚未完成首轮扫描"), ThingHKJsonContext.Default.ErrorResponse);
}
return Results.Json(snap, ThingHKJsonContext.Default.SensorSnapshot);
});
// SSE 推送流
app.MapGet("/stream", async (HttpContext ctx, CancellationToken ct) =>
{
// 强制 text/event-stream,禁用响应缓冲(SSE 必须 flush)
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
// 先推一次当前缓存快照,避免客户端等待一个 tick 才有数据
var initial = kernel.Scheduler.Cache.GetLatest();
if (initial != null)
{
await WriteSseEventAsync(ctx, initial, ct).ConfigureAwait(false);
}
// 订阅广播
await foreach (var snap in kernel.Scheduler.SubscribeAsync(ct).ConfigureAwait(false))
{
await WriteSseEventAsync(ctx, snap, ct).ConfigureAwait(false);
}
});
// 运行时配置调整
// 注意:async lambda 必须返回 Task<IResult>,单纯返回 IResult 会被框架当成 Task<IResult> 的未等待任务,导致响应体为空
app.MapPost("/config", async Task<IResult> (HttpContext ctx) =>
{
// 先读 body 为字符串,再反序列化。
// 在 AOT 下比 DeserializeAsync 对 nullable 属性更稳定。
using var reader = new StreamReader(ctx.Request.Body);
var body = await reader.ReadToEndAsync(ctx.RequestAborted);
ConfigRequest? req;
try
{
req = string.IsNullOrWhiteSpace(body)
? new ConfigRequest()
: JsonSerializer.Deserialize(body, ThingHKJsonContext.Default.ConfigRequest);
}
catch (JsonException ex)
{
ctx.Response.StatusCode = 400;
return Results.Json(new ErrorResponse("invalid_json", ex.Message), ThingHKJsonContext.Default.ErrorResponse);
}
if (req == null)
{
ctx.Response.StatusCode = 400;
return Results.Json(new ErrorResponse("invalid_body", "请求体为空"), ThingHKJsonContext.Default.ErrorResponse);
}
kernel.Scheduler.UpdateIntervals(req.FastIntervalMs, req.SlowIntervalMs);
return Results.Json(new ConfigResponse
{
Success = true,
FastIntervalMs = kernel.Scheduler.FastIntervalMs,
SlowIntervalMs = kernel.Scheduler.SlowIntervalMs,
StreamIntervalMs = kernel.Scheduler.FastIntervalMs,
}, ThingHKJsonContext.Default.ConfigResponse);
});
// 硬件监控配置:返回当前配置 + 可用硬件/传感器类型清单
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;
}
/// <summary>
/// 写一条 SSE 事件:event: snapshot\ndata: {json}\n\n
/// </summary>
private static async Task WriteSseEventAsync(HttpContext ctx, SensorSnapshot snap, CancellationToken ct)
{
await ctx.Response.WriteAsync("event: snapshot\n", ct).ConfigureAwait(false);
await ctx.Response.WriteAsync("data: ", ct).ConfigureAwait(false);
// 直接用流式序列化,避免大字符串分配
await JsonSerializer.SerializeAsync(
ctx.Response.Body, snap, ThingHKJsonContext.Default.SensorSnapshot, ct).ConfigureAwait(false);
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
await ctx.Response.Body.FlushAsync(ct).ConfigureAwait(false);
}
/// <summary>可用硬件分组清单(固定列表,供前端 Dialog 渲染)</summary>
private static List<HardwareTypeInfo> GetAvailableHardware(HardwareConfig cfg)
{
// (key, displayName, requiresAdmin)
var items = new (string, string, bool)[]
{
("cpu", "CPU", false),
("gpu", "GPUIntel/AMD/NVIDIA", false),
("memory", "内存", false),
("storage", "存储", true),
("motherboard", "主板", true),
("controller", "SuperIO/嵌入式控制器", true),
("battery", "电池", false),
("network", "网络", false),
("psu", "电源", true),
};
var list = new List<HardwareTypeInfo>(items.Length);
foreach (var (key, name, reqAdmin) in items)
{
list.Add(new HardwareTypeInfo
{
Key = key,
Name = name,
Enabled = cfg.IsHardwareEnabled(key),
RequiresAdmin = reqAdmin,
});
}
return list;
}
/// <summary>可用传感器类型清单(固定列表,供前端 Dialog 渲染)</summary>
private static List<SensorTypeInfo> GetAvailableSensorTypes(HardwareConfig cfg)
{
// (key, displayName)
var items = new (string, string)[]
{
("temperature", "温度"),
("load", "负载"),
("power", "功率"),
("clock", "时钟"),
("voltage", "电压"),
("fan", "风扇"),
("data", "容量"),
("smalldata", "小容量"),
("throughput", "吞吐"),
("level", "等级"),
("control", "控制"),
("factor", "因子"),
("frequency", "频率"),
("timespan", "时长"),
("energy", "能量"),
("noise", "噪声"),
("conductivity", "电导率"),
("humidity", "湿度"),
("flow", "流量"),
};
var list = new List<SensorTypeInfo>(items.Length);
foreach (var (key, name) in items)
{
list.Add(new SensorTypeInfo
{
Key = key,
Name = name,
Enabled = cfg.IsSensorTypeEnabled(key),
});
}
return list;
}
}
/// <summary>
/// Kernel 宿主:持有 HardwareManager + SamplingScheduler,管理整体生命周期。
/// Program.cs 构造此对象并注入到 DI 容器供 endpoint 使用。
/// </summary>
internal sealed class KernelHost : IDisposable
{
public HardwareManager? Hardware { get; private set; }
public SamplingScheduler Scheduler { get; private set; } = null!;
public Stopwatch Uptime { get; } = Stopwatch.StartNew();
/// <summary>配置文件路径(供 /config/hardware POST 保存)</summary>
public string? ConfigPath { get; private set; }
public async Task StartAsync(string? configPath, int fastMs, int slowMs)
{
ConfigPath = configPath;
var config = HardwareConfig.Load(configPath);
Console.Error.WriteLine($"[ThingHK] 加载配置: {configPath ?? "(默认)"}, 硬件={config.Hardware.Count}项 传感器类型={config.SensorTypes.Count}项");
// 1. 初始化硬件层(含首轮 Update,约 5 秒)
Hardware = new HardwareManager(config);
Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}");
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
Scheduler = new SamplingScheduler(Hardware, fastMs, slowMs);
await Scheduler.StartAsync();
Console.Error.WriteLine($"[ThingHK] 调度器已启动: fast={Scheduler.FastIntervalMs}ms slow={Scheduler.SlowIntervalMs}ms");
}
public void Dispose()
{
Console.Error.WriteLine("[ThingHK] Kernel 正在关闭...");
Scheduler?.Dispose();
Hardware?.Dispose();
}
}