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
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
bac1c27e72
commit
88b1a0a9bb
36 files changed
+742
-177
No files matched your search
@@ -13,7 +13,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
|||||||
**它不做什么**
|
**它不做什么**
|
||||||
|
|
||||||
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
|
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
|
||||||
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
|
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
|
||||||
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
|
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
|
||||||
- 暂不包含告警(见文末待办)。
|
- 暂不包含告警(见文末待办)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using MoShiro.Measurement;
|
||||||
|
|
||||||
namespace MoShiro.Agent;
|
namespace MoShiro.Agent;
|
||||||
|
|
||||||
@@ -28,16 +29,6 @@ public sealed class AgentConfig
|
|||||||
public string? RestartAt { get; set; }
|
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
|
public sealed class FleetSection
|
||||||
{
|
{
|
||||||
[JsonPropertyName("server")]
|
[JsonPropertyName("server")]
|
||||||
|
|||||||
+22
-8
@@ -815,7 +815,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"public"
|
"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",
|
"operationId": "hosts_list",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -841,16 +841,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/public/agents/{agent_id}/host": {
|
"/api/public/hosts/{node_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"public"
|
"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",
|
"operationId": "hosts_history",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "agent_id",
|
"name": "node_id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -6584,7 +6584,8 @@
|
|||||||
},
|
},
|
||||||
"HostItemDto": {
|
"HostItemDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"agent_id",
|
"id",
|
||||||
|
"kind",
|
||||||
"name",
|
"name",
|
||||||
"online",
|
"online",
|
||||||
"stale",
|
"stale",
|
||||||
@@ -6593,7 +6594,10 @@
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"agent_id": {
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
@@ -6622,7 +6626,7 @@
|
|||||||
},
|
},
|
||||||
"HostSeriesDto": {
|
"HostSeriesDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"agent_id",
|
"id",
|
||||||
"from",
|
"from",
|
||||||
"to",
|
"to",
|
||||||
"step",
|
"step",
|
||||||
@@ -6631,7 +6635,7 @@
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"agent_id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"from": {
|
"from": {
|
||||||
@@ -7379,6 +7383,10 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"isp_manual": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -8206,6 +8214,12 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"isp": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ public sealed class ProbeApp : IDisposable
|
|||||||
private readonly SemaphoreSlim _changed = new(0);
|
private readonly SemaphoreSlim _changed = new(0);
|
||||||
private readonly INodeLog _log;
|
private readonly INodeLog _log;
|
||||||
private readonly FleetLink _link;
|
private readonly FleetLink _link;
|
||||||
|
private readonly ReportingSection _reporting;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
|
/// <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)
|
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||||
{
|
{
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_time = time;
|
||||||
|
_reporting = file.Reporting;
|
||||||
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
||||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
|
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);
|
_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 link = _link.RunAsync(ct);
|
||||||
|
Task host = ReportHostAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
@@ -120,7 +125,41 @@ public sealed class ProbeApp : IDisposable
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await ReconcileAsync([], CancellationToken.None);
|
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 System.Text.Json.Serialization;
|
||||||
|
using MoShiro.Measurement;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.Probe;
|
namespace MoShiro.Probe;
|
||||||
@@ -18,6 +19,10 @@ public sealed class ProbeFile
|
|||||||
|
|
||||||
[JsonPropertyName("limits")]
|
[JsonPropertyName("limits")]
|
||||||
public ProbeLimits Limits { get; set; } = new();
|
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
|
public sealed class ProbeFleetSection
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
|
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
|
||||||
|
|||||||
+27
-7
@@ -1,13 +1,13 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.Agent;
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||||
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
|
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
|
||||||
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
|
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
|
||||||
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
|
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HostSampler
|
public sealed class HostSampler
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,7 @@ public sealed class HostSampler
|
|||||||
private readonly Func<(long Total, long Used)?> _disk;
|
private readonly Func<(long Total, long Used)?> _disk;
|
||||||
private readonly string _procRoot;
|
private readonly string _procRoot;
|
||||||
private readonly string _sysRoot;
|
private readonly string _sysRoot;
|
||||||
|
private readonly bool _native;
|
||||||
private (long Total, long Idle)? _previousCpu;
|
private (long Total, long Idle)? _previousCpu;
|
||||||
private (long At, long Rx, long Tx)? _previousNet;
|
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)
|
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
|
||||||
{
|
{
|
||||||
_time = time;
|
_time = time;
|
||||||
|
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
|
||||||
_read = read ?? ReadProc;
|
_read = read ?? ReadProc;
|
||||||
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
|
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
|
||||||
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
|
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
|
||||||
@@ -37,7 +39,8 @@ public sealed class HostSampler
|
|||||||
long now = _time.GetTimestamp();
|
long now = _time.GetTimestamp();
|
||||||
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
|
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)
|
if (_previousCpu is { } before && cpu.Total > before.Total)
|
||||||
{
|
{
|
||||||
@@ -48,7 +51,8 @@ public sealed class HostSampler
|
|||||||
_previousCpu = cpu;
|
_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.MemTotal = memory.Total;
|
||||||
snapshot.MemUsed = memory.Used;
|
snapshot.MemUsed = memory.Used;
|
||||||
@@ -60,11 +64,21 @@ public sealed class HostSampler
|
|||||||
snapshot.DiskUsed = disk.Used;
|
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.NetRxTotal = net.Rx;
|
||||||
snapshot.NetTxTotal = net.Tx;
|
snapshot.NetTxTotal = net.Tx;
|
||||||
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
|
snapshot.NetLinkMbps = net.LinkMbps;
|
||||||
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
|
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
|
||||||
{
|
{
|
||||||
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
|
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
|
||||||
@@ -83,6 +97,12 @@ public sealed class HostSampler
|
|||||||
return snapshot;
|
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>
|
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
|
||||||
public static (long Total, long Idle)? ParseCpu(string stat)
|
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.UpdateApply, RpcSide.Server),
|
||||||
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
|
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
|
||||||
|
|
||||||
// 节点面:Agent → Backend
|
// 节点面:节点 → Backend
|
||||||
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
|
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
|
||||||
MethodSpec.Notification(MoShiroMethods.Event, 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(监听方)
|
// 对等面:Probe(拨号方)→ Agent(监听方)
|
||||||
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
|
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
|
||||||
|
|||||||
@@ -179,15 +179,32 @@ public static class AgentEndpoints
|
|||||||
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
|
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
|
||||||
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
|
: 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);
|
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
|
||||||
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
|
{
|
||||||
|
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)
|
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 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(
|
public sealed record TaskDto(
|
||||||
string Id,
|
string Id,
|
||||||
|
|||||||
@@ -177,9 +177,9 @@ public sealed record HostDto(
|
|||||||
long? UptimeSeconds,
|
long? UptimeSeconds,
|
||||||
DateTimeOffset At);
|
DateTimeOffset At);
|
||||||
|
|
||||||
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
|
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
|
||||||
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</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>
|
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
|
||||||
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
|
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>
|
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
|
||||||
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
|
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>
|
/// <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;
|
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
|
public static class HostEndpoints
|
||||||
{
|
{
|
||||||
private const string Tag = "public";
|
private const string Tag = "public";
|
||||||
|
private const string AgentKind = "agent";
|
||||||
|
private const string ProbeKind = "probe";
|
||||||
|
|
||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/hosts", ListAsync)
|
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);
|
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
|
||||||
group.MapGet("/agents/{agent_id}/host", HistoryAsync)
|
group.MapGet("/hosts/{node_id}", HistoryAsync)
|
||||||
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
|
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
|
||||||
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
|
.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>();
|
var items = new List<HostItemDto>();
|
||||||
foreach (AgentView view in await agents.ListAsync(ct))
|
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);
|
(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);
|
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);
|
bool visible;
|
||||||
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
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)
|
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 now = time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
long from = now - span;
|
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 个点:按分钟的整数倍取步长,每个步长内取平均
|
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
|
||||||
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
|
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),
|
["rx_bps"] = Average(s => s.NetRxBps),
|
||||||
["tx_bps"] = Average(s => s.NetTxBps),
|
["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(
|
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)
|
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
|
||||||
{
|
{
|
||||||
string ipVersion = ip_version ?? IpVersions.V4;
|
string ipVersion = ip_version ?? IpVersions.V4;
|
||||||
@@ -84,7 +84,7 @@ public static class LiveEndpoints
|
|||||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
await http.Response.Body.FlushAsync(http.RequestAborted);
|
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);
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
|
||||||
await session.RunAsync(linked.Token);
|
await session.RunAsync(linked.Token);
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,8 @@ public static class LiveEndpoints
|
|||||||
{
|
{
|
||||||
private readonly HttpContext _http;
|
private readonly HttpContext _http;
|
||||||
private readonly AgentService _agents;
|
private readonly AgentService _agents;
|
||||||
|
private readonly ProbeService _probes;
|
||||||
|
private readonly HostStatusService _hosts;
|
||||||
private readonly LineService _lines;
|
private readonly LineService _lines;
|
||||||
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
|
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
|
||||||
private readonly string _ipVersion;
|
private readonly string _ipVersion;
|
||||||
@@ -124,10 +126,12 @@ public static class LiveEndpoints
|
|||||||
private long _lastWriteMs;
|
private long _lastWriteMs;
|
||||||
private long _lastPeriodicMs;
|
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;
|
_http = http;
|
||||||
_agents = agents;
|
_agents = agents;
|
||||||
|
_probes = probes;
|
||||||
|
_hosts = hosts;
|
||||||
_lines = lines;
|
_lines = lines;
|
||||||
_lineKeys = lineKeys;
|
_lineKeys = lineKeys;
|
||||||
_ipVersion = ipVersion;
|
_ipVersion = ipVersion;
|
||||||
@@ -171,7 +175,7 @@ public static class LiveEndpoints
|
|||||||
_nodeSignals.Enqueue(node);
|
_nodeSignals.Enqueue(node);
|
||||||
break;
|
break;
|
||||||
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
|
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
|
||||||
_hostSignals[host.AgentId] = host;
|
_hostSignals[host.Node] = host;
|
||||||
break;
|
break;
|
||||||
case ChunkSignal chunk:
|
case ChunkSignal chunk:
|
||||||
_dirtyLines.Add((chunk.AgentId, chunk.Series));
|
_dirtyLines.Add((chunk.AgentId, chunk.Series));
|
||||||
@@ -216,10 +220,20 @@ public static class LiveEndpoints
|
|||||||
|
|
||||||
foreach (HostSignal host in hostSignals)
|
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)
|
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:
|
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))
|
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;
|
break;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public static class NodeEndpoints
|
|||||||
var items = (await nodes.ListAsync(ct))
|
var items = (await nodes.ListAsync(ct))
|
||||||
.Where(n => visible.Contains(n.NodeId))
|
.Where(n => visible.Contains(n.NodeId))
|
||||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
.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();
|
.ToList();
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
|
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using MoShiro.Protocol;
|
|||||||
namespace MoShiro.Backend.Application;
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。
|
/// <c>moshiro.host</c>:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
|
||||||
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。
|
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HostStatusService
|
public sealed class HostStatusService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed record ChunkSignal(NodeId AgentId, string Series) : LiveSignal;
|
|||||||
public sealed record TestSignal(TestId TestId) : LiveSignal;
|
public sealed record TestSignal(TestId TestId) : LiveSignal;
|
||||||
|
|
||||||
/// <summary>某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。</summary>
|
/// <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
|
public sealed class LiveSubscription : IDisposable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ public static class IspClassifier
|
|||||||
return (Other, Shorten(org));
|
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);
|
private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
|
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public sealed record NodeInfo(
|
|||||||
string? Version = null,
|
string? Version = null,
|
||||||
string? Tag = null,
|
string? Tag = null,
|
||||||
string? Commit = null,
|
string? Commit = null,
|
||||||
string? BuiltAt = null);
|
string? BuiltAt = null,
|
||||||
|
bool IspManual = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
|
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ public sealed class NodeInfoService
|
|||||||
return [.. _cache.Values.Select(m => m.Info)];
|
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>
|
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
|
||||||
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
|
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -101,8 +126,15 @@ public sealed class NodeInfoService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
|
||||||
string country = geo.Country.ToUpperInvariant();
|
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);
|
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
|||||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||||
internal static class Schema
|
internal static class Schema
|
||||||
{
|
{
|
||||||
public const int CurrentVersion = 15;
|
public const int CurrentVersion = 16;
|
||||||
|
|
||||||
public static void Migrate(SqliteConnection connection)
|
public static void Migrate(SqliteConnection connection)
|
||||||
{
|
{
|
||||||
@@ -250,6 +250,15 @@ internal static class Schema
|
|||||||
connection.Exec("PRAGMA user_version = 15");
|
connection.Exec("PRAGMA user_version = 15");
|
||||||
transaction.Commit();
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,20 +14,20 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
|
|||||||
|
|
||||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
|
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
|
||||||
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
|
_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);
|
ct);
|
||||||
|
|
||||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
|
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
|
||||||
_db.ExecuteAsync(
|
_db.ExecuteAsync(
|
||||||
c => c.Exec(
|
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) " +
|
"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) ON CONFLICT(node_id) DO UPDATE SET " +
|
"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, " +
|
"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, " +
|
"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),
|
("@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),
|
("@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);
|
ct);
|
||||||
|
|
||||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
|
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||||
@@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
|
|||||||
private static NodeMeta Read(SqliteDataReader r)
|
private static NodeMeta Read(SqliteDataReader r)
|
||||||
{
|
{
|
||||||
static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i);
|
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 System.Text.Json.Nodes;
|
||||||
using MoShiro.Agent;
|
using MoShiro.Agent;
|
||||||
|
using MoShiro.Measurement;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
using MoShiro.TestSupport;
|
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"));
|
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]
|
[Fact]
|
||||||
public void Sample_DerivesCpuPercentAndNetworkSpeedFromTheDifferenceBetweenTwoReads()
|
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 using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts");
|
||||||
await agent.SendHostAsync(Snapshot(18.5));
|
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.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue<double>());
|
||||||
Assert.True(live.Data["online"]!.GetValue<bool>());
|
Assert.True(live.Data["online"]!.GetValue<bool>());
|
||||||
|
|
||||||
// REST:最新值,没有过期
|
// REST:最新值,没有过期
|
||||||
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
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.False(item["stale"]!.GetValue<bool>());
|
||||||
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
|
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
|
||||||
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue<long>());
|
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue<long>());
|
||||||
@@ -150,7 +170,7 @@ public sealed class HostStatusTests
|
|||||||
JsonNode history = null!;
|
JsonNode history = null!;
|
||||||
await BackendFixture.EventuallyAsync(async () =>
|
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);
|
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>());
|
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 对匿名访客不可见
|
// 隐藏的 Agent 对匿名访客不可见
|
||||||
await _backend.Admin.PatchAsync($"/api/admin/agents/{id}", BackendFixture.Json(new JsonObject { ["public_visible"] = false }));
|
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");
|
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
||||||
Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["agent_id"]!.GetValue<string>() == id);
|
Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == id);
|
||||||
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/agents/{id}/host");
|
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/hosts/{id}");
|
||||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode);
|
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
@@ -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/hosts` | 每个对外可见的 Agent 和没被吊销的 Probe 的最新快照:`{id, kind, name, online, stale, last_seen_at, host}`(`kind` 是 `agent` 或 `probe`);超过 15 s 没有更新 `stale` 为真(离线,或者节点关了上报) |
|
||||||
| `GET /api/public/agents/{agent_id}/host?range=1h` | 历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` |
|
| `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 天)。
|
`host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。
|
||||||
|
|
||||||
### 2.3.2 节点的展示信息
|
### 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、构建时间,不含提交号)。
|
`GET /api/public/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- 在**若干 Probe**(家宽等需要观测的出口,可以有多个)与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;哪些 Probe 连哪些 Agent 由面板决定(自动全连,也可以手动选择);
|
- 在**若干 Probe**(家宽等需要观测的出口,可以有多个)与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;哪些 Probe 连哪些 Agent 由面板决定(自动全连,也可以手动选择);
|
||||||
- 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比;
|
- 由 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. 拓扑
|
## 2. 拓扑
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问
|
|||||||
- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
|
- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
|
||||||
- **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
|
- **身份**:节点使用本地生成的 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`)。
|
- **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。
|
||||||
|
|
||||||
## 6. 可扩展性目标
|
## 6. 可扩展性目标
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
|
|||||||
| `moshiro.test.cancel` | B→A | 请求 | `test_id` |
|
| `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.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.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 自报的值会被覆盖。
|
心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd
|
|||||||
|
|
||||||
磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。
|
磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 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)
|
## 容器节点的更新(Agent、Probe)
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ Probe 启动
|
|||||||
|
|
||||||
## Agent 的本地配置:主机状态上报
|
## Agent 的本地配置:主机状态上报
|
||||||
|
|
||||||
`agent.json` 的 `reporting` 段(面板无法修改):
|
`agent.json` 和 `probe.json` 的 `reporting` 段(面板无法修改):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "reporting": { "host_stats": true, "host_interval_s": 2 } }
|
{ "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 就一个字也不上报。
|
||||||
@@ -19,10 +19,19 @@ services:
|
|||||||
# .env 里的变量原样传进容器(不用带默认值的变量替换:有的 compose 提供者,比如 podman-compose,不替换环境变量里的默认值)
|
# .env 里的变量原样传进容器(不用带默认值的变量替换:有的 compose 提供者,比如 podman-compose,不替换环境变量里的默认值)
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
HOST_PROC: /host/proc
|
||||||
|
HOST_SYS: /host/sys
|
||||||
|
HOST_ROOT: /host/root
|
||||||
volumes:
|
volumes:
|
||||||
- probe-config:/etc/moshiro
|
- probe-config:/etc/moshiro
|
||||||
- probe-status:/etc/moshiro/update-status:ro
|
- probe-status:/etc/moshiro/update-status:ro
|
||||||
- /etc/os-release:/etc/os-release: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
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
|
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
|
||||||
|
|||||||
@@ -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 }
|
type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto }
|
||||||
|
|
||||||
export function Probes() {
|
export function Probes() {
|
||||||
@@ -200,6 +209,8 @@ export function Probes() {
|
|||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [renaming, setRenaming] = useState<ProbeDto>()
|
const [renaming, setRenaming] = useState<ProbeDto>()
|
||||||
const [pending, setPending] = useState<Pending>()
|
const [pending, setPending] = useState<Pending>()
|
||||||
|
// 刚改过的运营商(公开的节点信息每分钟才刷新一次,先按这里显示)
|
||||||
|
const [isp, setIsp] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const list = probes.data?.items ?? []
|
const list = probes.data?.items ?? []
|
||||||
|
|
||||||
@@ -246,6 +257,21 @@ export function Probes() {
|
|||||||
)}
|
)}
|
||||||
<span className="mono faint">{p.id}</span>
|
<span className="mono faint">{p.id}</span>
|
||||||
<span className="right row wrap">
|
<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)}>
|
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
改名
|
改名
|
||||||
|
|||||||
@@ -1257,3 +1257,69 @@ input {
|
|||||||
.agent-card:has(.pop.open) {
|
.agent-card:has(.pop.open) {
|
||||||
z-index: 30;
|
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;
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ export interface Host {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HostItem {
|
export interface HostItem {
|
||||||
agent_id: string
|
id: string
|
||||||
|
kind: 'agent' | 'probe'
|
||||||
name: string
|
name: string
|
||||||
online: boolean
|
online: boolean
|
||||||
stale: 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 } {
|
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
|
let alive = true
|
||||||
api
|
api
|
||||||
.get<Page<HostItem>>('/api/public/hosts')
|
.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)
|
.catch(() => undefined)
|
||||||
|
|
||||||
const source = openLive('/api/public/live?topics=hosts,nodes')
|
const source = openLive('/api/public/live?topics=hosts,nodes')
|
||||||
source.onopen = () => setLive(true)
|
source.onopen = () => setLive(true)
|
||||||
source.onerror = () => setLive(false)
|
source.onerror = () => setLive(false)
|
||||||
source.addEventListener('host', (event) => {
|
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) => {
|
setHosts((previous) => {
|
||||||
const next = new Map(previous)
|
const next = new Map(previous)
|
||||||
const old = previous.get(data.agent_id)
|
const old = previous.get(data.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 })
|
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
|
return next
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function ewma(values: (number | null)[], alpha: number): (number | null)[] {
|
|||||||
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
||||||
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
||||||
const history = useQuery(
|
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],
|
[agentId, range],
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,14 +16,22 @@ const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50',
|
|||||||
|
|
||||||
type SortKey = 'name' | 'rtt' | 'loss'
|
type SortKey = 'name' | 'rtt' | 'loss'
|
||||||
|
|
||||||
interface AgentCard {
|
/** 线路按 Probe 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
|
||||||
id: string
|
const CARRIERS = [
|
||||||
name: string
|
{ key: 'telecom', name: '电信' },
|
||||||
labels: string[]
|
{ key: 'unicom', name: '联通' },
|
||||||
online: boolean
|
{ key: 'mobile', name: '移动' },
|
||||||
lines: OverviewLineDto[]
|
{ 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 }) {
|
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
|
||||||
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
|
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> }) {
|
/** 一个节点(Agent 或 Probe)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
|
||||||
const h = host?.host ?? null
|
function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map<string, NodeInfoDto> }) {
|
||||||
const stale = !agent.online || !host || host.stale
|
const h = node.host
|
||||||
const info = infos.get(agent.id)
|
const stale = !node.online || node.stale
|
||||||
|
const info = infos.get(node.id)
|
||||||
return (
|
return (
|
||||||
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
|
<div className={`card agent-card ${node.online ? '' : 'offline'}`}>
|
||||||
<div className="agent-head">
|
<div className="agent-head">
|
||||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||||
{info?.country ? <Flag code={info.country} /> : <Dot tone={agent.online ? 'ok' : 'bad'} />}
|
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
|
||||||
<span className="agent-name">{agent.name}</span>
|
<span className="agent-name">{node.name}</span>
|
||||||
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
|
<span className="faint kind-tag">{KIND_NAME[node.kind]}</span>
|
||||||
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
|
<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>
|
</div>
|
||||||
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
|
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
|
||||||
<div className="facts">
|
<div className="facts">
|
||||||
@@ -82,34 +92,14 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{agent.labels.length > 0 ? (
|
{labels.length > 0 ? (
|
||||||
<div className="line-meta">
|
<div className="line-meta">
|
||||||
{agent.labels.map((x) => (
|
{labels.map((x) => (
|
||||||
<Chip key={x}>{x}</Chip>
|
<Chip key={x}>{x}</Chip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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' : ''}`}>
|
<div className={`host ${stale ? 'stale' : ''}`}>
|
||||||
{h ? (
|
{h ? (
|
||||||
<>
|
<>
|
||||||
@@ -126,7 +116,7 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
|
|||||||
<div className="stat">
|
<div className="stat">
|
||||||
<span className="k">网速</span>
|
<span className="k">网速</span>
|
||||||
<span className="num">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
@@ -140,15 +130,43 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="faint">这台 Agent 没有上报主机状态</div>
|
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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() {
|
export function Overview() {
|
||||||
const [probe, setProbe] = useState<string>('')
|
const [carrier, setCarrier] = useState<'' | CarrierKey>('')
|
||||||
const [sort, setSort] = useState<SortKey>('name')
|
const [sort, setSort] = useState<SortKey>('name')
|
||||||
const { hosts, live } = useHosts()
|
const { hosts, live } = useHosts()
|
||||||
const infos = useNodeInfos()
|
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 { 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])
|
// 节点卡片:先 Agent 后 Probe,各自在线的在前、再按名字;Agent 的标签来自它的线路
|
||||||
const cards = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
const byAgent = new Map<string, AgentCard>()
|
const labels = new Map<string, string[]>()
|
||||||
for (const l of data?.lines ?? []) {
|
for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels)
|
||||||
if (ipVersionOf(l.ip_version) !== ipVersion) continue
|
const rank = (n: HostItem) => (n.kind === 'agent' ? 0 : 1)
|
||||||
if (probe && l.probe_name !== probe) continue
|
return [...hosts.values()]
|
||||||
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
|
.sort((a, b) => rank(a) - rank(b) || Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
|
||||||
card.lines.push(l)
|
.map((node) => ({ node, labels: labels.get(node.id) ?? [] }))
|
||||||
byAgent.set(l.agent_id, card)
|
}, [hosts, data])
|
||||||
}
|
// 线路面板:按 IP 版本过滤,再排序
|
||||||
const best = (c: AgentCard, key: string, pick: (a: number, b: number) => number) => {
|
const lines = useMemo(() => {
|
||||||
const values = c.lines.map((l) => l.summary.metrics[key]).filter((v): v is number => v !== null && v !== undefined)
|
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
|
||||||
return values.length ? values.reduce(pick) : null
|
|
||||||
}
|
|
||||||
return [...byAgent.values()].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 key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
|
||||||
const x = best(a, key, Math.min)
|
return picked.sort((a, b) => {
|
||||||
const y = best(b, key, Math.min)
|
if (a.online !== b.online) return a.online ? -1 : 1
|
||||||
if (x === null && y === null) return a.name.localeCompare(b.name)
|
const byName = () => a.name.localeCompare(b.name) || a.probe_name.localeCompare(b.probe_name)
|
||||||
if (x === null) return 1
|
if (sort === 'name') return byName()
|
||||||
if (y === null) return -1
|
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
|
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 (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<h1>总览</h1>
|
<h1>总览</h1>
|
||||||
<p className="sub">
|
<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 ? '实时更新中' : '实时连接中断,正在重连'} />
|
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||||
</p>
|
</p>
|
||||||
<div className="toolbar">
|
</section>
|
||||||
<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}
|
<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">
|
<span className="right row">
|
||||||
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
|
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
|
||||||
<Segmented
|
<Segmented
|
||||||
@@ -220,27 +264,40 @@ export function Overview() {
|
|||||||
value={sort}
|
value={sort}
|
||||||
onChange={setSort}
|
onChange={setSort}
|
||||||
/>
|
/>
|
||||||
{loading && !data ? <Spinner /> : null}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<PageError error={error} />
|
|
||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
cards.length === 0 ? (
|
lines.length === 0 ? (
|
||||||
<div className="card" style={{ marginTop: 24 }}>
|
<div>
|
||||||
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
|
<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}
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className="cards">
|
shown.map((g) => {
|
||||||
{cards.map((c) => (
|
const rtt = average(g.lines.map((l) => l.summary.metrics['rtt.p50']))
|
||||||
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
|
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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: 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"];
|
get: operations["hosts_list"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
@@ -174,14 +174,14 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/public/agents/{agent_id}/host": {
|
"/api/public/hosts/{node_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: 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"];
|
get: operations["hosts_history"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
@@ -1592,7 +1592,8 @@ export interface components {
|
|||||||
at: unknown;
|
at: unknown;
|
||||||
};
|
};
|
||||||
HostItemDto: {
|
HostItemDto: {
|
||||||
agent_id: string;
|
id: string;
|
||||||
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
@@ -1601,7 +1602,7 @@ export interface components {
|
|||||||
host: null | components["schemas"]["HostDto"];
|
host: null | components["schemas"]["HostDto"];
|
||||||
};
|
};
|
||||||
HostSeriesDto: {
|
HostSeriesDto: {
|
||||||
agent_id: string;
|
id: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
from: unknown;
|
from: unknown;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1766,6 +1767,8 @@ export interface components {
|
|||||||
isp_name: null | string;
|
isp_name: null | string;
|
||||||
version?: null | string;
|
version?: null | string;
|
||||||
tag?: null | string;
|
tag?: null | string;
|
||||||
|
/** @default false */
|
||||||
|
isp_manual: boolean;
|
||||||
};
|
};
|
||||||
NodeOwnerDto: {
|
NodeOwnerDto: {
|
||||||
credential_id: string;
|
credential_id: string;
|
||||||
@@ -1939,6 +1942,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
PatchProbeRequest: {
|
PatchProbeRequest: {
|
||||||
name: null | string;
|
name: null | string;
|
||||||
|
isp?: null | string;
|
||||||
};
|
};
|
||||||
PatchTaskRequest: {
|
PatchTaskRequest: {
|
||||||
params: null | Record<string, never>;
|
params: null | Record<string, never>;
|
||||||
@@ -2832,7 +2836,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
agent_id: string;
|
node_id: string;
|
||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user