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 -1
View File
@@ -13,7 +13,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
**它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。
+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"]); // 节点还在,但一个字也没上报
}
}
+3 -3
View File
@@ -91,14 +91,14 @@
| 端点 | 内容 |
| --- | --- |
| `GET /api/public/hosts` | 每个对外可见的 Agent 的最新快照:`{agent_id, name, online, stale, last_seen_at, host}`;超过 15 s 没有更新 `stale` 为真(离线,或者 Agent 关了上报) |
| `GET /api/public/agents/{agent_id}/host?range=1h` | 历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` |
| `GET /api/public/hosts` | 每个对外可见的 Agent 和没被吊销的 Probe 的最新快照:`{id, kind, name, online, stale, last_seen_at, host}`(`kind` 是 `agent` 或 `probe`);超过 15 s 没有更新 `stale` 为真(离线,或者节点关了上报) |
| `GET /api/public/hosts/{node_id}?range=1h` | Agent 或 Probe 的历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` |
`host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。
### 2.3.2 节点的展示信息
`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。**没有 IP 地址、城市、ASN、经纬度**,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。
`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name, isp_manual}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。**没有 IP 地址、城市、ASN、经纬度**,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。
`GET /api/public/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。
+2 -2
View File
@@ -6,7 +6,7 @@
- 在**若干 Probe**(家宽等需要观测的出口,可以有多个)与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;哪些 Probe 连哪些 Agent 由面板决定(自动全连,也可以手动选择);
- 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比;
- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 不上报主机资源。节点只在连接面板时自报粗粒度的系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新;国家和运营商由 Backend 用节点的来源地址查询,节点自己不访问任何外部服务。展示页看不到任何 IP。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、自更新等能力:更新由节点宿主机上的更新器完成,节点进程只往数据目录放面板转来的发布清单和更新请求。
- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 和 Probe 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 的上报可以在 `probe.json` 里关掉(`reporting.host_stats: false`)。展示页用卡片画出每个 Agent 和 Probe 的主机状态,线路单独放在卡片下面的面板里。节点只在连接面板时自报粗粒度的系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新;国家和运营商由 Backend 用节点的来源地址查询,节点自己不访问任何外部服务。展示页看不到任何 IP。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、自更新等能力:更新由节点宿主机上的更新器完成,节点进程只往数据目录放面板转来的发布清单和更新请求。
## 2. 拓扑
@@ -113,7 +113,7 @@ Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问
- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
- **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
- **防放大**:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。
- **容器里的主机指标**:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 只需要挂载 `/etc/os-release`。
- **容器里的主机指标**:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 的 compose 也一样挂载这几项。
- **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。
## 6. 可扩展性目标
+1 -1
View File
@@ -33,7 +33,7 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
| `moshiro.test.cancel` | B→A | 请求 | `test_id` |
| `moshiro.test.status` | A→B | 通知 | `test_id`、`status`(`running`、`phase_done`、`succeeded`、`failed`、`aborted`)、`phase?`、`error_code?` |
| `moshiro.event` | A→B | 通知 | `kind`(`probe_connected`(`detail` 是 `probe_id`,`probe` 里带版本、远端地址和连接时间)、`probe_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 Probe 或 Agent 上报))、`at_utc`、`detail` |
| `moshiro.host` | A→B | 通知 | `HostSnapshot`:主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP),每 `host_interval_s` 秒一次;`host_stats` 为 false 时不发,见 [enrollment.md](../security/enrollment.md) |
| `moshiro.host` | A、P→B | 通知 | `HostSnapshot`:主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP),每 `host_interval_s` 秒一次;`host_stats` 为 false 时不发,见 [enrollment.md](../security/enrollment.md) |
心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。
+1 -1
View File
@@ -302,7 +302,7 @@ Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd
磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。
Probe 保持现有功能,只上报系统、架构与节点状态,不上报 CPU、内存、磁盘或网卡指标。因此 Probe 的 Compose 模板只需要配置目录和 `/etc/os-release` 挂载;从 Agent systemd 示例改为 Probe 时,去掉 `/proc`、`/sys`、`/` 挂载及 `HOST_*` 环境变量。
Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 compose 和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。
## 容器节点的更新(Agent、Probe)
+2 -2
View File
@@ -100,10 +100,10 @@ Probe 启动
## Agent 的本地配置:主机状态上报
`agent.json` 的 `reporting` 段(面板无法修改):
`agent.json` 和 `probe.json` 的 `reporting` 段(面板无法修改):
```json
{ "reporting": { "host_stats": true, "host_interval_s": 2 } }
```
`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
`host_stats` 为 true(默认)时,Agent 和 Probe 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
+9
View File
@@ -19,10 +19,19 @@ services:
# .env 里的变量原样传进容器(不用带默认值的变量替换:有的 compose 提供者,比如 podman-compose,不替换环境变量里的默认值)
env_file:
- .env
environment:
HOST_PROC: /host/proc
HOST_SYS: /host/sys
HOST_ROOT: /host/root
volumes:
- probe-config:/etc/moshiro
- probe-status:/etc/moshiro/update-status:ro
- /etc/os-release:/etc/os-release:ro
# 主机资源占用(CPU、内存、磁盘、网卡流量、运行时长,只有汇总数字,没有型号和 IP):只读挂载宿主机的 /proc、/sys 和根目录;
# 不想上报:在 probe.json 里写 reporting.host_stats=false,再删掉下面三行挂载。Docker Desktop 上看到的是它的虚拟机,不是 Windows 或 macOS 本身。
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
+26
View File
@@ -191,6 +191,15 @@ function NameDialog({ title, initial, onClose, onSubmit }: { title: string; init
)
}
const ISP_CHOICES = [
{ value: 'auto', label: '运营商:自动识别' },
{ value: 'telecom', label: '中国电信' },
{ value: 'unicom', label: '中国联通' },
{ value: 'mobile', label: '中国移动' },
{ value: 'broadnet', label: '中国广电' },
{ value: 'other', label: '其他' },
]
type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
@@ -200,6 +209,8 @@ export function Probes() {
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>()
// 刚改过的运营商(公开的节点信息每分钟才刷新一次,先按这里显示)
const [isp, setIsp] = useState<Record<string, string>>({})
const list = probes.data?.items ?? []
@@ -246,6 +257,21 @@ export function Probes() {
)}
<span className="mono faint">{p.id}</span>
<span className="right row wrap">
<select
className="input small"
title="展示页按电信 / 联通 / 移动给线路分组,自动识别不准时在这里手动指定"
value={isp[p.id] ?? (infos.get(p.id)?.isp_manual ? infos.get(p.id)?.isp_key ?? 'auto' : 'auto')}
onChange={async (e) => {
const value = e.target.value
if (await run(() => api.patch(`/api/admin/probes/${p.id}`, { isp: value }), '已保存')) setIsp((m) => ({ ...m, [p.id]: value }))
}}
>
{ISP_CHOICES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
<Pencil size={14} />
改名
+66
View File
@@ -1257,3 +1257,69 @@ input {
.agent-card:has(.pop.open) {
z-index: 30;
}
/* ── 总览:节点卡片下面单独的「线路」面板 ── */
.agent-name + .kind-tag {
font-size: 12px;
}
.lines-panel {
margin-top: 24px;
}
.cards + .lines-panel {
margin-top: 16px;
}
.lines-panel .lines-toolbar {
margin-top: 0;
}
.lines-panel .agent-head .faint {
font-size: 12px;
}
.line-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.line-card {
padding: 10px 12px;
}
.line-card .line-name {
flex-wrap: wrap;
row-gap: 2px;
}
.line-card .line-name .arrow {
color: var(--app-ink-faint);
}
/* 线路面板:按三网分组 */
.carrier-group + .carrier-group {
margin-top: 18px;
}
.carrier-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.carrier-head .carrier-sum {
display: flex;
gap: 6px;
align-items: baseline;
font-size: 13px;
}
.carrier-head .isp {
width: 20px;
height: 20px;
flex: none;
}
+7 -6
View File
@@ -20,7 +20,8 @@ export interface Host {
}
export interface HostItem {
agent_id: string
id: string
kind: 'agent' | 'probe'
name: string
online: boolean
stale: boolean
@@ -29,7 +30,7 @@ export interface HostItem {
}
/**
* Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* Agent 和 Probe 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。
*/
export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
@@ -40,18 +41,18 @@ export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
let alive = true
api
.get<Page<HostItem>>('/api/public/hosts')
.then((page) => alive && setHosts(new Map(page.items.map((i) => [i.agent_id, i]))))
.then((page) => alive && setHosts(new Map(page.items.map((i) => [i.id, i]))))
.catch(() => undefined)
const source = openLive('/api/public/live?topics=hosts,nodes')
source.onopen = () => setLive(true)
source.onerror = () => setLive(false)
source.addEventListener('host', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { agent_id: string; online: boolean; host: Host }
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; kind: 'agent' | 'probe'; online: boolean; host: Host }
setHosts((previous) => {
const next = new Map(previous)
const old = previous.get(data.agent_id)
next.set(data.agent_id, { agent_id: data.agent_id, name: old?.name ?? data.agent_id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
const old = previous.get(data.id)
next.set(data.id, { id: data.id, kind: data.kind, name: old?.name ?? data.id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
return next
})
})
+1 -1
View File
@@ -103,7 +103,7 @@ function ewma(values: (number | null)[], alpha: number): (number | null)[] {
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
const history = useQuery(
() => (agentId && range ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)),
() => (agentId && range ? api.get<HostSeries>(`/api/public/hosts/${agentId}`, { range }) : Promise.resolve(undefined)),
[agentId, range],
30_000,
)
+143 -86
View File
@@ -16,14 +16,22 @@ const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50',
type SortKey = 'name' | 'rtt' | 'loss'
interface AgentCard {
id: string
name: string
labels: string[]
online: boolean
lines: OverviewLineDto[]
/** 线路按 Probe 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
const CARRIERS = [
{ key: 'telecom', name: '电信' },
{ key: 'unicom', name: '联通' },
{ key: 'mobile', name: '移动' },
{ key: 'other', name: '其他' },
] as const
type CarrierKey = (typeof CARRIERS)[number]['key']
const carrierOf = (info: NodeInfoDto | undefined): CarrierKey => (info?.isp_key === 'telecom' || info?.isp_key === 'unicom' || info?.isp_key === 'mobile' ? info.isp_key : 'other')
const average = (values: Array<number | null | undefined>) => {
const v = values.filter((x): x is number => typeof x === 'number')
return v.length === 0 ? null : v.reduce((a, b) => a + b, 0) / v.length
}
const KIND_NAME = { agent: 'Agent', probe: 'Probe' } as const
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
@@ -39,18 +47,20 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
const info = infos.get(agent.id)
/** 一个节点(Agent 或 Probe)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map<string, NodeInfoDto> }) {
const h = node.host
const stale = !node.online || node.stale
const info = infos.get(node.id)
return (
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
<div className={`card agent-card ${node.online ? '' : 'offline'}`}>
<div className="agent-head">
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <Dot tone={agent.online ? 'ok' : 'bad'} />}
<span className="agent-name">{agent.name}</span>
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
<span className="agent-name">{node.name}</span>
<span className="faint kind-tag">{KIND_NAME[node.kind]}</span>
<Chip tone={node.online ? 'ok' : 'bad'}>{node.online ? '在线' : '离线'}</Chip>
{!node.online && node.last_seen_at ? <span className="faint">{fmtAgo(node.last_seen_at)}</span> : null}
</div>
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
<div className="facts">
@@ -82,34 +92,14 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
) : null}
</div>
) : null}
{agent.labels.length > 0 ? (
{labels.length > 0 ? (
<div className="line-meta">
{agent.labels.map((x) => (
{labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
) : null}
<div className="lines">
{agent.lines.map((l) => {
const m = l.summary.metrics
return (
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
<div className="line-top">
<span className="line-name" title={[l.probe_name, infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version].filter(Boolean).join(' · ')}>
<span className="arrow">→</span>
{infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
<span className="name">{l.probe_name}</span>
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
</span>
{!l.probe_connected && l.online ? <span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。"><Chip tone="warn">测量通道未建立</Chip></span> : <span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>}
</div>
<LineStrips lineId={l.id} range={range} m={m} />
</Link>
)
})}
</div>
<div className={`host ${stale ? 'stale' : ''}`}>
{h ? (
<>
@@ -126,7 +116,7 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
<div className="stat">
<span className="k">网速</span>
<span className="num">
↑ {fmtSpeed(agent.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(agent.online ? h.net_rx_bps : null)}
↑ {fmtSpeed(node.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(node.online ? h.net_rx_bps : null)}
</span>
</div>
<div className="stat">
@@ -140,15 +130,43 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
</div>
</>
) : (
<div className="faint">这台 Agent 没有上报主机状态</div>
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
)}
</div>
</div>
)
}
/** 一条线路(Probe → Agent):近一段时间的 UDP 丢包和延迟走势、TCP 的 RTT 和重传。 */
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const probeInfo = infos.get(l.probe_id)
const agentInfo = infos.get(l.agent_id)
return (
<Link to={`/line/${encodeURIComponent(l.id)}`} className="line-link line-card">
<div className="line-top">
<span className="line-name" title={[l.probe_name, probeInfo?.tag || probeInfo?.version].filter(Boolean).join(' · ')}>
{probeInfo?.country ? <Flag code={probeInfo.country} /> : null}
<span className="name">{l.probe_name}</span>
{probeInfo?.isp_key ? <IspIcon ispKey={probeInfo.isp_key} name={probeInfo.isp_name} /> : null}
<span className="arrow">→</span>
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
<span className="name">{l.name}</span>
</span>
{!l.probe_connected && l.online ? (
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。">
<Chip tone="warn">测量通道未建立</Chip>
</span>
) : (
<span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
)}
</div>
<LineStrips lineId={l.id} range={range} m={l.summary.metrics} />
</Link>
)
}
export function Overview() {
const [probe, setProbe] = useState<string>('')
const [carrier, setCarrier] = useState<'' | CarrierKey>('')
const [sort, setSort] = useState<SortKey>('name')
const { hosts, live } = useHosts()
const infos = useNodeInfos()
@@ -169,46 +187,72 @@ export function Overview() {
)
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000)
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
const cards = useMemo(() => {
const byAgent = new Map<string, AgentCard>()
for (const l of data?.lines ?? []) {
if (ipVersionOf(l.ip_version) !== ipVersion) continue
if (probe && l.probe_name !== probe) continue
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
card.lines.push(l)
byAgent.set(l.agent_id, card)
}
const best = (c: AgentCard, key: string, pick: (a: number, b: number) => number) => {
const values = c.lines.map((l) => l.summary.metrics[key]).filter((v): v is number => v !== null && v !== undefined)
return values.length ? values.reduce(pick) : null
}
return [...byAgent.values()].sort((a, b) => {
// 节点卡片:先 Agent 后 Probe,各自在线的在前、再按名字;Agent 的标签来自它的线路
const nodes = useMemo(() => {
const labels = new Map<string, string[]>()
for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels)
const rank = (n: HostItem) => (n.kind === 'agent' ? 0 : 1)
return [...hosts.values()]
.sort((a, b) => rank(a) - rank(b) || Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
.map((node) => ({ node, labels: labels.get(node.id) ?? [] }))
}, [hosts, data])
// 线路面板:按 IP 版本过滤,再排序
const lines = useMemo(() => {
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
return picked.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
if (sort === 'name') return a.name.localeCompare(b.name)
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
const x = best(a, key, Math.min)
const y = best(b, key, Math.min)
if (x === null && y === null) return a.name.localeCompare(b.name)
if (x === null) return 1
if (y === null) return -1
const byName = () => a.name.localeCompare(b.name) || a.probe_name.localeCompare(b.probe_name)
if (sort === 'name') return byName()
const x = a.summary.metrics[key]
const y = b.summary.metrics[key]
if ((x === null || x === undefined) && (y === null || y === undefined)) return byName()
if (x === null || x === undefined) return 1
if (y === null || y === undefined) return -1
return x - y
})
}, [data, probe, sort, ipVersion])
}, [data, sort, ipVersion])
// 按 Probe 的运营商分组(固定顺序,没有线路的组不显示)
const groups = useMemo(
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.probe_id)) === c.key) })).filter((g) => g.lines.length > 0),
[lines, infos],
)
const shown = groups.filter((g) => !carrier || g.key === carrier)
const online = cards.filter((c) => c.online).length
const count = (kind: 'agent' | 'probe') => {
const all = nodes.filter((n) => n.node.kind === kind)
return `${all.filter((n) => n.node.online).length} / ${all.length}`
}
return (
<main className="page">
<section className="hero">
<h1>总览</h1>
<p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
{hosts.size > 0 || data ? `Agent ${count('agent')} 在线 · Probe ${count('probe')} 在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</p>
<div className="toolbar">
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setProbe(''); setParams(next, { replace: true }) }} />
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
</section>
<PageError error={error} />
{nodes.length > 0 ? (
<div className="cards">
{nodes.map(({ node, labels }) => (
<NodeCard key={node.id} node={node} labels={labels} infos={infos} />
))}
</div>
) : null}
<section className="card agent-card lines-panel">
<div className="agent-head">
<span className="agent-name">线路</span>
<span className="faint">{data ? `${lines.length} 条` : ''}</span>
{loading && !data ? <Spinner /> : null}
</div>
<div className="toolbar lines-toolbar">
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} />
{groups.length > 1 ? <Segmented<'' | CarrierKey> options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : null}
<span className="right row">
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
<Segmented
@@ -220,27 +264,40 @@ export function Overview() {
value={sort}
onChange={setSort}
/>
{loading && !data ? <Spinner /> : null}
</span>
</div>
{data ? (
lines.length === 0 ? (
<div>
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
</div>
) : (
shown.map((g) => {
const rtt = average(g.lines.map((l) => l.summary.metrics['rtt.p50']))
const loss = average(g.lines.map((l) => l.summary.metrics['loss.total']))
return (
<div key={g.key} className="carrier-group">
<div className="carrier-head">
{g.key !== 'other' ? <IspIcon ispKey={g.key} name={g.name} /> : null}
<b>{g.name}</b>
<span className="faint">{g.lines.length} 条</span>
<span className="right carrier-sum num">
<span className="faint">平均延迟</span> {rtt === null ? '—' : `${rtt.toFixed(1)} ms`}
<span className="faint">平均丢包</span> {loss === null ? '—' : `${loss.toFixed(2)}%`}
</span>
</div>
<div className="line-grid">
{g.lines.map((l) => (
<LineCard key={l.id} l={l} range={range} infos={infos} />
))}
</div>
</div>
)
})
)
) : null}
</section>
<PageError error={error} />
{data ? (
cards.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
</div>
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
))}
</div>
)
) : null}
</main>
)
}
+10 -6
View File
@@ -164,7 +164,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Latest host resource usage of every visible agent (no hardware model, no addresses) */
/** Latest host resource usage of every visible agent and probe (no hardware model, no addresses) */
get: operations["hosts_list"];
put?: never;
post?: never;
@@ -174,14 +174,14 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/public/agents/{agent_id}/host": {
"/api/public/hosts/{node_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Host resource usage history of one agent (columnar, averaged per step) */
/** Host resource usage history of one agent or probe (columnar, averaged per step) */
get: operations["hosts_history"];
put?: never;
post?: never;
@@ -1592,7 +1592,8 @@ export interface components {
at: unknown;
};
HostItemDto: {
agent_id: string;
id: string;
kind: string;
name: string;
online: boolean;
stale: boolean;
@@ -1601,7 +1602,7 @@ export interface components {
host: null | components["schemas"]["HostDto"];
};
HostSeriesDto: {
agent_id: string;
id: string;
/** Format: date-time */
from: unknown;
/** Format: date-time */
@@ -1766,6 +1767,8 @@ export interface components {
isp_name: null | string;
version?: null | string;
tag?: null | string;
/** @default false */
isp_manual: boolean;
};
NodeOwnerDto: {
credential_id: string;
@@ -1939,6 +1942,7 @@ export interface components {
};
PatchProbeRequest: {
name: null | string;
isp?: null | string;
};
PatchTaskRequest: {
params: null | Record<string, never>;
@@ -2832,7 +2836,7 @@ export interface operations {
};
header?: never;
path: {
agent_id: string;
node_id: string;
};
cookie?: never;
};