feat: probe host stats, node cards with a separate lines panel grouped by carrier, Windows host sampling, manual carrier override [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-08 21:56:19 +08:00
1 parent bac1c27e72
commit 88b1a0a9bb
36 files changed
+750 -185

No files matched your search

+1 -10
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent;
@@ -28,16 +29,6 @@ public sealed class AgentConfig
public string? RestartAt { get; set; }
}
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
public sealed class FleetSection
{
[JsonPropertyName("server")]
+22 -8
View File
@@ -815,7 +815,7 @@
"tags": [
"public"
],
"summary": "Latest host resource usage of every visible agent (no hardware model, no addresses)",
"summary": "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)",
"operationId": "hosts_list",
"responses": {
"200": {
@@ -841,16 +841,16 @@
}
}
},
"/api/public/agents/{agent_id}/host": {
"/api/public/hosts/{node_id}": {
"get": {
"tags": [
"public"
],
"summary": "Host resource usage history of one agent (columnar, averaged per step)",
"summary": "Host resource usage history of one agent or probe (columnar, averaged per step)",
"operationId": "hosts_history",
"parameters": [
{
"name": "agent_id",
"name": "node_id",
"in": "path",
"required": true,
"schema": {
@@ -6584,7 +6584,8 @@
},
"HostItemDto": {
"required": [
"agent_id",
"id",
"kind",
"name",
"online",
"stale",
@@ -6593,7 +6594,10 @@
],
"type": "object",
"properties": {
"agent_id": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"name": {
@@ -6622,7 +6626,7 @@
},
"HostSeriesDto": {
"required": [
"agent_id",
"id",
"from",
"to",
"step",
@@ -6631,7 +6635,7 @@
],
"type": "object",
"properties": {
"agent_id": {
"id": {
"type": "string"
},
"from": {
@@ -7379,6 +7383,10 @@
"null",
"string"
]
},
"isp_manual": {
"type": "boolean",
"default": false
}
}
},
@@ -8206,6 +8214,12 @@
"null",
"string"
]
},
"isp": {
"type": [
"null",
"string"
]
}
}
},
+40 -1
View File
@@ -22,11 +22,15 @@ public sealed class ProbeApp : IDisposable
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
private readonly FleetLink _link;
private readonly ReportingSection _reporting;
private readonly TimeProvider _time;
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_log = log;
_time = time;
_reporting = file.Reporting;
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
@@ -105,6 +109,7 @@ public sealed class ProbeApp : IDisposable
}
Task link = _link.RunAsync(ct);
Task host = ReportHostAsync(ct);
try
{
while (!ct.IsCancellationRequested)
@@ -120,7 +125,41 @@ public sealed class ProbeApp : IDisposable
finally
{
await ReconcileAsync([], CancellationToken.None);
await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
}
}
/// <summary>每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。</summary>
private async Task ReportHostAsync(CancellationToken ct)
{
if (!_reporting.HostStats)
{
return;
}
var sampler = new HostSampler(_time);
sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
if (_link.CurrentSession is { } session)
{
try
{
await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_log.Warn($"could not report host status: {ex.Message}");
}
}
}
}
catch (OperationCanceledException)
{
// 停止
}
}
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
@@ -18,6 +19,10 @@ public sealed class ProbeFile
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
/// <summary>上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 <c>host_stats: false</c> 就一个字也不上报。</summary>
[JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new();
}
public sealed class ProbeFleetSection
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
@@ -1,13 +1,13 @@
using System.Globalization;
using MoShiro.Protocol;
namespace MoShiro.Agent;
namespace MoShiro.Measurement;
/// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
/// </summary>
public sealed class HostSampler
{
@@ -18,6 +18,7 @@ public sealed class HostSampler
private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot;
private readonly string _sysRoot;
private readonly bool _native;
private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet;
@@ -25,6 +26,7 @@ public sealed class HostSampler
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{
_time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
@@ -37,7 +39,8 @@ public sealed class HostSampler
long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null;
if (cpuNow is { } cpu)
{
if (_previousCpu is { } before && cpu.Total > before.Total)
{
@@ -48,7 +51,8 @@ public sealed class HostSampler
_previousCpu = cpu;
}
if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
if (memoryNow is { } memory)
{
snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used;
@@ -60,11 +64,21 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used;
}
if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
(long Rx, long Tx, long? LinkMbps)? netNow = null;
if (_native)
{
netNow = WindowsNetwork();
}
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
{
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
}
if (netNow is { } net)
{
snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
snapshot.NetLinkMbps = net.LinkMbps;
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -83,6 +97,12 @@ public sealed class HostSampler
return snapshot;
}
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat)
{
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace MoShiro.Measurement;
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):Agent 和 Probe 共用。</summary>
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
@@ -0,0 +1,125 @@
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace MoShiro.Measurement;
/// <summary>
/// Windows 上读主机资源占用(<see cref="HostSampler"/> 在 Linux 之外的实现):CPU 时间和物理内存用两个只读的系统调用
/// (<c>GetSystemTimes</c>、<c>GlobalMemoryStatusEx</c>),网卡流量和链路速率用 .NET 自带的 <see cref="NetworkInterface"/>。
/// 和 Linux 一样只有汇总的数字:不读网卡名、MAC、IP,也不启动任何进程。
/// </summary>
[SupportedOSPlatform("windows")]
internal static partial class WindowsHost
{
private static readonly string[] s_virtualWords = ["Virtual", "vEthernet", "Hyper-V", "VMware", "VirtualBox", "TAP-", "Wintun", "WireGuard", "Npcap", "Bluetooth", "Loopback", "isatap", "Teredo"];
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
public readonly long Value => (long)(((ulong)High << 32) | Low);
}
[StructLayout(LayoutKind.Sequential)]
private struct MemoryStatus
{
public uint Length;
public uint MemoryLoad;
public ulong TotalPhys;
public ulong AvailPhys;
public ulong TotalPageFile;
public ulong AvailPageFile;
public ulong TotalVirtual;
public ulong AvailVirtual;
public ulong AvailExtendedVirtual;
}
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GlobalMemoryStatusEx(ref MemoryStatus status);
/// <summary>系统启动以来的 CPU 总时间和空闲时间(和 Linux 的 <c>/proc/stat</c> 第一行同一个意思:两次采样相减得到占用)。</summary>
public static (long Total, long Idle)? Cpu()
{
try
{
if (!GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user))
{
return null;
}
// 内核时间里已经包含了空闲时间
return (kernel.Value + user.Value, idle.Value);
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
return null;
}
}
/// <summary>物理内存的总量和已用(总量减去可用)。</summary>
public static (long Total, long Used)? Memory()
{
try
{
var status = new MemoryStatus { Length = (uint)Marshal.SizeOf<MemoryStatus>() };
if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0)
{
return null;
}
return ((long)status.TotalPhys, (long)(status.TotalPhys - Math.Min(status.AvailPhys, status.TotalPhys)));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
return null;
}
}
/// <summary>所有物理网卡(在用的,排除回环、隧道、虚拟网卡)的累计收发字节,以及链路速率之和(Mbit/s;有一块读不到速率就是 null)。</summary>
public static (long Rx, long Tx, long? LinkMbps)? Network()
{
try
{
long rx = 0;
long tx = 0;
long mbps = 0;
bool speedKnown = true;
bool any = false;
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel
|| s_virtualWords.Any(w => nic.Description.Contains(w, StringComparison.OrdinalIgnoreCase) || nic.Name.Contains(w, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
IPInterfaceStatistics stats = nic.GetIPStatistics();
rx += stats.BytesReceived;
tx += stats.BytesSent;
any = true;
if (nic.Speed > 0)
{
mbps += nic.Speed / 1_000_000;
}
else
{
speedKnown = false;
}
}
return any ? (rx, tx, speedKnown && mbps > 0 ? mbps : null) : null;
}
catch (NetworkInformationException)
{
return null;
}
}
}
@@ -34,10 +34,10 @@ public static class MoShiroMethodTable
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
// 节点面:Agent → Backend
// 节点面:节点 → Backend
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总
// 对等面:Probe(拨号方)→ Agent(监听方)
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
@@ -179,15 +179,32 @@ public static class AgentEndpoints
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct)
{
if (request.Name is null)
if (request.Name is null && request.Isp is null)
{
return Problems.From(http, ValidationError.Single("name", "is required"));
return Problems.From(http, ValidationError.Single("name", "name or isp is required"));
}
Result<ProbeView> result = await probes.RenameAsync(id, request.Name, ct);
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
{
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
}
Result<ProbeView> result = request.Name is null
? (await probes.GetAsync(id, ct) is { } existing ? Result<ProbeView>.Ok(existing) : Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found"))
: await probes.RenameAsync(id, request.Name, ct);
if (!result.TryGet(out ProbeView? view, out FleetError? error))
{
return Problems.From(http, error);
}
if (request.Isp is not null)
{
await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct);
}
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
@@ -125,7 +125,8 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record PatchProbeRequest(string? Name);
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary>
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
public sealed record TaskDto(
string Id,
@@ -177,9 +177,9 @@ public sealed record HostDto(
long? UptimeSeconds,
DateTimeOffset At);
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null);
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
@@ -187,9 +187,10 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param>
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
@@ -7,22 +7,27 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>Agent 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。</summary>
/// <summary>
/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。
/// </summary>
public static class HostEndpoints
{
private const string Tag = "public";
private const string AgentKind = "agent";
private const string ProbeKind = "probe";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/hosts", ListAsync)
.Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag)
.Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag)
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
group.MapGet("/agents/{agent_id}/host", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
group.MapGet("/hosts/{node_id}", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
}
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct)
{
var items = new List<HostItemDto>();
foreach (AgentView view in await agents.ListAsync(ct))
@@ -33,18 +38,39 @@ public static class HostEndpoints
}
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
}
foreach (ProbeView view in await probes.ListAsync(ct))
{
if (view.Revoked)
{
continue;
}
(HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id));
items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host)));
}
return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
}
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct)
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
{
AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct);
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
bool visible;
if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal))
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http));
}
else
{
visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false };
}
if (!visible)
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found");
}
if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day)
@@ -54,7 +80,7 @@ public static class HostEndpoints
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
long from = now - span;
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct);
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(new NodeId(node_id), from, now + 60_000, ct);
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
@@ -92,6 +118,6 @@ public static class HostEndpoints
["rx_bps"] = Average(s => s.NetRxBps),
["tx_bps"] = Average(s => s.NetTxBps),
};
return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
return TypedResults.Json(new HostSeriesDto(node_id, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
}
}
@@ -32,7 +32,7 @@ public static class LiveEndpoints
}
private static async Task<IResult> StreamAsync(
HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
{
string ipVersion = ip_version ?? IpVersions.V4;
@@ -84,7 +84,7 @@ public static class LiveEndpoints
http.Response.Headers["X-Accel-Buffering"] = "no";
await http.Response.Body.FlushAsync(http.RequestAborted);
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
var session = new LiveSession(http, agents, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
await session.RunAsync(linked.Token);
}
@@ -105,6 +105,8 @@ public static class LiveEndpoints
{
private readonly HttpContext _http;
private readonly AgentService _agents;
private readonly ProbeService _probes;
private readonly HostStatusService _hosts;
private readonly LineService _lines;
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
private readonly string _ipVersion;
@@ -124,10 +126,12 @@ public static class LiveEndpoints
private long _lastWriteMs;
private long _lastPeriodicMs;
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
{
_http = http;
_agents = agents;
_probes = probes;
_hosts = hosts;
_lines = lines;
_lineKeys = lineKeys;
_ipVersion = ipVersion;
@@ -171,7 +175,7 @@ public static class LiveEndpoints
_nodeSignals.Enqueue(node);
break;
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
_hostSignals[host.AgentId] = host;
_hostSignals[host.Node] = host;
break;
case ChunkSignal chunk:
_dirtyLines.Add((chunk.AgentId, chunk.Series));
@@ -216,10 +220,20 @@ public static class LiveEndpoints
foreach (HostSignal host in hostSignals)
{
AgentView? view = await _agents.GetAsync(host.AgentId, ct);
if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe)
{
await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
continue;
}
AgentView? view = await _agents.GetAsync(host.Node, ct);
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
{
await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
}
@@ -284,7 +298,15 @@ public static class LiveEndpoints
case LiveTopicKind.Hosts:
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null))
{
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
}
foreach (ProbeView view in await _probes.ListAsync(ct))
{
if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host)
{
await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct);
}
}
break;
@@ -61,7 +61,7 @@ public static class NodeEndpoints
var items = (await nodes.ListAsync(ct))
.Where(n => visible.Contains(n.NodeId))
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null))
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
@@ -4,8 +4,8 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。
/// <c>moshiro.host</c>:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。
/// </summary>
public sealed class HostStatusService
{
@@ -17,7 +17,7 @@ public sealed record ChunkSignal(NodeId AgentId, string Series) : LiveSignal;
public sealed record TestSignal(TestId TestId) : LiveSignal;
/// <summary>某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。</summary>
public sealed record HostSignal(NodeId AgentId, HostSnapshot Host) : LiveSignal;
public sealed record HostSignal(NodeId Node, HostSnapshot Host) : LiveSignal;
public sealed class LiveSubscription : IDisposable
{
@@ -66,6 +66,17 @@ public static class IspClassifier
return (Other, Shorten(org));
}
/// <summary>手动指定运营商时的中文名;不认识的取值返回 null。</summary>
public static string? NameOf(string key) => key switch
{
Telecom => "中国电信",
Unicom => "中国联通",
Mobile => "中国移动",
Broadnet => "中国广电",
Other => "其他",
_ => null,
};
private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
@@ -16,7 +16,8 @@ public sealed record NodeInfo(
string? Version = null,
string? Tag = null,
string? Commit = null,
string? BuiltAt = null);
string? BuiltAt = null,
bool IspManual = false);
/// <summary>
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
@@ -42,6 +42,31 @@ public sealed class NodeInfoService
return [.. _cache.Values.Select(m => m.Info)];
}
/// <summary>
/// 手动指定节点的运营商(telecom / unicom / mobile / broadnet / other);传 null 回到自动识别(下次节点出现时重新查)。
/// 返回 false 表示取值不认识。
/// </summary>
public async ValueTask<bool> SetIspAsync(string nodeId, string kind, string? key, CancellationToken ct)
{
await EnsureLoadedAsync(ct);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
if (key is null)
{
// 清掉手动值,并让下一次心跳重新查询
await SaveAsync(current with { Info = current.Info with { IspKey = null, IspName = null, IspManual = false }, GeoHost = null, GeoAtMs = 0, UpdatedAtMs = Now() }, ct);
return true;
}
string? name = IspClassifier.NameOf(key);
if (name is null)
{
return false;
}
await SaveAsync(current with { Info = current.Info with { IspKey = key, IspName = name, IspManual = true }, UpdatedAtMs = Now() }, ct);
return true;
}
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
{
@@ -101,8 +126,15 @@ public sealed class NodeInfoService
return;
}
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
string country = geo.Country.ToUpperInvariant();
if (current.Info.IspManual)
{
// 运营商是手动指定的:只更新国家,不覆盖运营商
await SaveAsync(current with { Info = current.Info with { Country = country }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
return;
}
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
}
finally
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
internal static class Schema
{
public const int CurrentVersion = 15;
public const int CurrentVersion = 16;
public static void Migrate(SqliteConnection connection)
{
@@ -250,6 +250,15 @@ internal static class Schema
connection.Exec("PRAGMA user_version = 15");
transaction.Commit();
}
if (version < 16)
{
// 运营商可以手动指定(自动识别会错,例如 CN2 线路):手动的不会被后面的 IP 归属查询覆盖
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE node_meta ADD COLUMN isp_manual INTEGER NOT NULL DEFAULT 0");
connection.Exec("PRAGMA user_version = 16");
transaction.Commit();
}
}
/// <summary>
@@ -14,20 +14,20 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at FROM node_meta", Read),
c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read),
ct);
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
_db.ExecuteAsync(
c => c.Exec(
"INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at) " +
"VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt) ON CONFLICT(node_id) DO UPDATE SET " +
"INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " +
"VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " +
"kind = excluded.kind, os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " +
"isp_key = excluded.isp_key, isp_name = excluded.isp_name, geo_host = excluded.geo_host, geo_at = excluded.geo_at, updated_at = excluded.updated_at, " +
"version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at",
"version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at, isp_manual = excluded.isp_manual",
("@id", meta.Info.NodeId), ("@kind", meta.Info.Kind), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country),
("@ik", meta.Info.IspKey), ("@in", meta.Info.IspName), ("@gh", meta.GeoHost), ("@ga", meta.GeoAtMs), ("@ua", meta.UpdatedAtMs),
("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt)),
("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt), ("@im", meta.Info.IspManual ? 1 : 0)),
ct);
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
@@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
private static NodeMeta Read(SqliteDataReader r)
{
static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i);
return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14)), S(r, 8), r.GetInt64(9), r.GetInt64(10));
return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14), r.GetInt64(15) != 0), S(r, 8), r.GetInt64(9), r.GetInt64(10));
}
}
@@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using MoShiro.Agent;
using MoShiro.Measurement;
using MoShiro.Protocol;
using MoShiro.TestSupport;
@@ -24,6 +25,23 @@ public sealed class HostSamplerTests
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1 + "podman0: 8888 1 0 0 0 0 0 0 9999 1 0 0 0 0 0 0\n"));
}
[Fact]
public async Task Sample_OnWindows_ReadsCpuMemoryAndNetworkFromTheSystem()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var sampler = new HostSampler(TimeProvider.System);
sampler.Sample();
await Task.Delay(300, TestContext.Current.CancellationToken);
HostSnapshot snapshot = sampler.Sample();
Assert.NotNull(snapshot.CpuPercent);
Assert.InRange(snapshot.CpuPercent!.Value, 0, 100);
Assert.True(snapshot.MemTotal > 0 && snapshot.MemUsed >= 0 && snapshot.MemUsed <= snapshot.MemTotal);
}
[Fact]
public void Sample_DerivesCpuPercentAndNetworkSpeedFromTheDifferenceBetweenTwoReads()
{
@@ -123,13 +141,15 @@ public sealed class HostStatusTests
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts");
await agent.SendHostAsync(Snapshot(18.5));
SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["agent_id"]!.GetValue<string>() == id);
SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["id"]!.GetValue<string>() == id);
Assert.Equal("agent", live.Data!["kind"]!.GetValue<string>());
Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue<double>());
Assert.True(live.Data["online"]!.GetValue<bool>());
// REST:最新值,没有过期
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
JsonNode item = list["items"]!.AsArray().Single(i => i!["agent_id"]!.GetValue<string>() == id)!;
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == id)!;
Assert.Equal("agent", item["kind"]!.GetValue<string>());
Assert.False(item["stale"]!.GetValue<bool>());
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue<long>());
@@ -150,7 +170,7 @@ public sealed class HostStatusTests
JsonNode history = null!;
await BackendFixture.EventuallyAsync(async () =>
{
history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/agents/{id}/host?range=1h");
history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/hosts/{id}?range=1h");
return history["values"]!["cpu"]!.AsArray().Any(v => v is not null);
});
Assert.Equal(25.0, history["values"]!["mem"]!.AsArray().Last(v => v is not null)!.GetValue<double>());
@@ -159,8 +179,61 @@ public sealed class HostStatusTests
// 隐藏的 Agent 对匿名访客不可见
await _backend.Admin.PatchAsync($"/api/admin/agents/{id}", BackendFixture.Json(new JsonObject { ["public_visible"] = false }));
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["agent_id"]!.GetValue<string>() == id);
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/agents/{id}/host");
Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == id);
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/hosts/{id}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode);
}
[Fact]
public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked()
{
await _backend.SiteMustBePublicAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => file.Reporting.HostIntervalSeconds = 1);
JsonNode? item = null;
await BackendFixture.EventuallyAsync(
async () =>
{
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
item = list["items"]!.AsArray().SingleOrDefault(i => i!["id"]!.GetValue<string>() == probe.ProbeId);
return item?["host"] is not null;
},
TimeSpan.FromSeconds(30),
"the probe's host status shows up");
Assert.Equal("probe", item!["kind"]!.GetValue<string>());
Assert.NotNull(item["host"]!["uptime_seconds"]); // 任何平台都读得到运行时长
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
{
Assert.DoesNotContain(forbidden, item.ToJsonString(), StringComparison.OrdinalIgnoreCase);
}
// 历史也按节点 id 取
using HttpResponseMessage history = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}?range=1h");
Assert.Equal(System.Net.HttpStatusCode.OK, history.StatusCode);
// 吊销之后展示页就看不到它了
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{probe.ProbeId}/revoke", null);
Assert.True(revoked.IsSuccessStatusCode);
JsonNode after = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
Assert.DoesNotContain(after["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == probe.ProbeId);
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, gone.StatusCode);
}
[Fact]
public async Task AProbeThatTurnsReportingOff_SendsNothing()
{
await _backend.SiteMustBePublicAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file =>
{
file.Reporting.HostStats = false;
file.Reporting.HostIntervalSeconds = 1;
});
await Task.Delay(TimeSpan.FromSeconds(4));
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == probe.ProbeId)!;
Assert.Null(item["host"]); // 节点还在,但一个字也没上报
}
}