Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
257 lines
9.7 KiB
C#
257 lines
9.7 KiB
C#
using System.Globalization;
|
||
using MoShiro.Protocol;
|
||
|
||
namespace MoShiro.Measurement;
|
||
|
||
/// <summary>
|
||
/// 拨号方和监听方共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
|
||
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
|
||
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
|
||
/// </summary>
|
||
public sealed class HostSampler
|
||
{
|
||
private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "podman", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"];
|
||
|
||
private readonly TimeProvider _time;
|
||
private readonly Func<string, string?> _read;
|
||
private readonly Func<(long Total, long Used)?> _disk;
|
||
private readonly string _procRoot;
|
||
private readonly string _sysRoot;
|
||
private readonly bool _native;
|
||
private (long Total, long Idle)? _previousCpu;
|
||
private (long At, long Rx, long Tx)? _previousNet;
|
||
|
||
public HostSampler(TimeProvider time, Func<string, string?>? read = null, Func<(long Total, long Used)?>? disk = null,
|
||
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
|
||
{
|
||
_time = time;
|
||
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
|
||
_read = read ?? ReadProc;
|
||
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
|
||
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
|
||
string root = Root(diskRoot, "HOST_ROOT", OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/");
|
||
_disk = disk ?? (() => ReadRootDisk(root));
|
||
}
|
||
|
||
public HostSnapshot Sample()
|
||
{
|
||
long now = _time.GetTimestamp();
|
||
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
|
||
|
||
(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)
|
||
{
|
||
double busy = 1.0 - ((double)(cpu.Idle - before.Idle) / (cpu.Total - before.Total));
|
||
snapshot.CpuPercent = Math.Round(Math.Clamp(busy * 100, 0, 100), 1);
|
||
}
|
||
|
||
_previousCpu = cpu;
|
||
}
|
||
|
||
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
|
||
if (memoryNow is { } memory)
|
||
{
|
||
snapshot.MemTotal = memory.Total;
|
||
snapshot.MemUsed = memory.Used;
|
||
}
|
||
|
||
if (_disk() is { } disk)
|
||
{
|
||
snapshot.DiskTotal = disk.Total;
|
||
snapshot.DiskUsed = disk.Used;
|
||
}
|
||
|
||
(long Rx, long Tx, long? LinkMbps)? netNow = null;
|
||
if (_native)
|
||
{
|
||
netNow = WindowsNetwork();
|
||
}
|
||
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
|
||
{
|
||
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
|
||
}
|
||
|
||
if (netNow is { } net)
|
||
{
|
||
snapshot.NetRxTotal = net.Rx;
|
||
snapshot.NetTxTotal = net.Tx;
|
||
snapshot.NetLinkMbps = net.LinkMbps;
|
||
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
|
||
{
|
||
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
|
||
snapshot.NetRxBps = (long)((net.Rx - before.Rx) / seconds);
|
||
snapshot.NetTxBps = (long)((net.Tx - before.Tx) / seconds);
|
||
}
|
||
|
||
_previousNet = (now, net.Rx, net.Tx);
|
||
}
|
||
|
||
snapshot.UptimeSeconds = _read(Path.Combine(_procRoot, "uptime")) is { } uptime && ParseUptime(uptime) is { } up ? up : null;
|
||
if (!OperatingSystem.IsLinux() && snapshot.UptimeSeconds is null)
|
||
{
|
||
snapshot.UptimeSeconds = Environment.TickCount64 / 1000;
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
|
||
|
||
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
|
||
|
||
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
|
||
|
||
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
|
||
public static (long Total, long Idle)? ParseCpu(string stat)
|
||
{
|
||
string? line = stat.Split('\n').FirstOrDefault(l => l.StartsWith("cpu ", StringComparison.Ordinal));
|
||
if (line is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
long[] fields = [.. line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Skip(1).Take(8).Select(f => long.TryParse(f, CultureInfo.InvariantCulture, out long v) ? v : 0)];
|
||
if (fields.Length < 5)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return (fields.Sum(), fields[3] + fields[4]);
|
||
}
|
||
|
||
/// <summary>MemTotal 与 MemAvailable(kB)→ 字节;已用 = 总量 − 可用。</summary>
|
||
public static (long Total, long Used)? ParseMemory(string meminfo)
|
||
{
|
||
long? total = null;
|
||
long? available = null;
|
||
foreach (string line in meminfo.Split('\n'))
|
||
{
|
||
if (line.StartsWith("MemTotal:", StringComparison.Ordinal))
|
||
{
|
||
total = KiloBytes(line);
|
||
}
|
||
else if (line.StartsWith("MemAvailable:", StringComparison.Ordinal))
|
||
{
|
||
available = KiloBytes(line);
|
||
}
|
||
}
|
||
|
||
return total is { } t && available is { } a ? (t, Math.Max(0, t - a)) : null;
|
||
}
|
||
|
||
/// <summary>所有物理网卡(排除回环、容器、虚拟网桥)的累计收发字节。</summary>
|
||
public static (long Rx, long Tx)? ParseNetwork(string dev)
|
||
{
|
||
long rx = 0;
|
||
long tx = 0;
|
||
bool any = false;
|
||
foreach (string line in dev.Split('\n'))
|
||
{
|
||
int colon = line.IndexOf(':', StringComparison.Ordinal);
|
||
if (colon < 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string name = line[..colon].Trim();
|
||
if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal)))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string[] fields = line[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
if (fields.Length >= 9 && long.TryParse(fields[0], CultureInfo.InvariantCulture, out long r) && long.TryParse(fields[8], CultureInfo.InvariantCulture, out long t))
|
||
{
|
||
rx += r;
|
||
tx += t;
|
||
any = true;
|
||
}
|
||
}
|
||
|
||
return any ? (rx, tx) : null;
|
||
}
|
||
|
||
public static long? ParseUptime(string uptime)
|
||
{
|
||
string first = uptime.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
|
||
return double.TryParse(first, NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds) ? (long)seconds : null;
|
||
}
|
||
|
||
private static long? KiloBytes(string line)
|
||
{
|
||
string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
return parts.Length >= 2 && long.TryParse(parts[1], CultureInfo.InvariantCulture, out long kb) ? kb * 1024 : null;
|
||
}
|
||
|
||
private static string? ReadProc(string path)
|
||
{
|
||
try
|
||
{
|
||
return OperatingSystem.IsLinux() && File.Exists(path) ? File.ReadAllText(path) : null;
|
||
}
|
||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static string Root(string? value, string variable, string fallback)
|
||
{
|
||
value ??= Environment.GetEnvironmentVariable(variable);
|
||
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||
}
|
||
|
||
private long? ReadLinkSpeed(string dev)
|
||
{
|
||
long total = 0;
|
||
bool any = false;
|
||
foreach (string line in dev.Split('\n'))
|
||
{
|
||
int colon = line.IndexOf(':', StringComparison.Ordinal);
|
||
if (colon < 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string name = line[..colon].Trim();
|
||
if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal)))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string? speed = _read(Path.Combine(_sysRoot, "class/net", name, "speed"));
|
||
if (long.TryParse(speed?.Trim(), CultureInfo.InvariantCulture, out long mbps) && mbps > 0)
|
||
{
|
||
total += mbps;
|
||
any = true;
|
||
}
|
||
else
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return any ? total : null;
|
||
}
|
||
|
||
private static (long Total, long Used)? ReadRootDisk(string root)
|
||
{
|
||
try
|
||
{
|
||
if (!Directory.Exists(root))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var drive = new DriveInfo(root);
|
||
return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null;
|
||
}
|
||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
}
|