using System.Globalization; using MoShiro.Protocol; namespace MoShiro.Measurement; /// /// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 只有汇总的数字:不采集 CPU 型号、主机名、网卡名和任何 IP 地址,所以面板和展示页想泄漏也泄漏不出去。 /// Linux 读 /proc 和 /sys;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 /// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。 /// 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 _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? 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; /// 第一行 cpu user nice system idle iowait irq softirq steal …:总时间与空闲时间(idle + iowait)。 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]); } /// MemTotal 与 MemAvailable(kB)→ 字节;已用 = 总量 − 可用。 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; } /// 所有物理网卡(排除回环、容器、虚拟网桥)的累计收发字节。 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; } } }