Implemented node system statistics
This commit is contained in:
36
MoonlightServers.Daemon/Helpers/NativeMethods.cs
Normal file
36
MoonlightServers.Daemon/Helpers/NativeMethods.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MoonlightServers.Daemon.Helpers;
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct StatVfsResult
|
||||
{
|
||||
public ulong bsize;
|
||||
public ulong frsize;
|
||||
public ulong blocks;
|
||||
public ulong bfree;
|
||||
public ulong bavail;
|
||||
public ulong files;
|
||||
public ulong ffree;
|
||||
public ulong favail;
|
||||
public ulong fsid;
|
||||
public ulong flag;
|
||||
public ulong namemax;
|
||||
private ulong __spare0; // } kernel reserved padding —
|
||||
private ulong __spare1; // } never read, exist only to
|
||||
private ulong __spare2; // } match the 112-byte struct
|
||||
private ulong __spare3; // } statvfs layout on x86-64
|
||||
private ulong __spare4; // } Linux so the fields above
|
||||
private ulong __spare5; // } land at the right offsets
|
||||
}
|
||||
|
||||
// SetLastError = true tells the marshaller to capture errno immediately
|
||||
// after the call, before any other code can clobber it. Retrieve it with
|
||||
// Marshal.GetLastPInvokeError() which maps to the thread-local errno value.
|
||||
[LibraryImport("libc", EntryPoint = "statvfs",
|
||||
StringMarshalling = StringMarshalling.Utf8,
|
||||
SetLastError = true)]
|
||||
internal static partial int StatVfs(string path, out StatVfsResult buf);
|
||||
}
|
||||
76
MoonlightServers.Daemon/Helpers/SystemMetrics.Cpu.cs
Normal file
76
MoonlightServers.Daemon/Helpers/SystemMetrics.Cpu.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
namespace MoonlightServers.Daemon.Helpers;
|
||||
|
||||
public partial class SystemMetrics
|
||||
{
|
||||
public record CpuSnapshot(
|
||||
string ModelName,
|
||||
double TotalUsagePercent,
|
||||
IReadOnlyList<double> CoreUsagePercents
|
||||
);
|
||||
|
||||
private record RawCpuLine(
|
||||
long User,
|
||||
long Nice,
|
||||
long System,
|
||||
long Idle,
|
||||
long Iowait,
|
||||
long Irq,
|
||||
long Softirq,
|
||||
long Steal
|
||||
);
|
||||
|
||||
private static async Task<List<RawCpuLine>> ReadRawCpuStatsAsync()
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync("/proc/stat");
|
||||
var result = new List<RawCpuLine>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
// All cpu* lines appear at the top of the file; stop on the first non-cpu line.
|
||||
if (!line.StartsWith("cpu", StringComparison.Ordinal))
|
||||
break;
|
||||
|
||||
var p = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (p.Length < 8)
|
||||
continue;
|
||||
|
||||
result.Add(new RawCpuLine(
|
||||
User: long.Parse(p[1]),
|
||||
Nice: long.Parse(p[2]),
|
||||
System: long.Parse(p[3]),
|
||||
Idle: long.Parse(p[4]),
|
||||
Iowait: long.Parse(p[5]),
|
||||
Irq: long.Parse(p[6]),
|
||||
Softirq: long.Parse(p[7]),
|
||||
Steal: p.Length > 8 ? long.Parse(p[8]) : 0L
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadCpuModelNameAsync()
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync("/proc/cpuinfo");
|
||||
var line = lines.FirstOrDefault(l => l.StartsWith("model name", StringComparison.OrdinalIgnoreCase));
|
||||
return line is not null ? line.Split(':')[1].Trim() : "Unknown";
|
||||
}
|
||||
|
||||
private static CpuSnapshot ComputeCpuUsage(string modelName, List<RawCpuLine> s1, List<RawCpuLine> s2)
|
||||
{
|
||||
// Index 0 = aggregate "cpu" row; indices 1+ = "cpu0", "cpu1"
|
||||
var totalUsage = s1.Count > 0 ? Usage(s1[0], s2[0]) : 0.0;
|
||||
var coreUsages = s1.Skip(1).Zip(s2.Skip(1), Usage).ToList();
|
||||
|
||||
return new CpuSnapshot(modelName, totalUsage, coreUsages);
|
||||
|
||||
static double Usage(RawCpuLine a, RawCpuLine b)
|
||||
{
|
||||
var idleDelta = (b.Idle + b.Iowait) - (a.Idle + a.Iowait);
|
||||
var totalDelta = (b.User + b.Nice + b.System + b.Idle + b.Iowait + b.Irq + b.Softirq + b.Steal)
|
||||
- (a.User + a.Nice + a.System + a.Idle + a.Iowait + a.Irq + a.Softirq + a.Steal);
|
||||
return totalDelta <= 0 ? 0.0 : Math.Round((1.0 - (double)idleDelta / totalDelta) * 100.0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
149
MoonlightServers.Daemon/Helpers/SystemMetrics.Disk.cs
Normal file
149
MoonlightServers.Daemon/Helpers/SystemMetrics.Disk.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MoonlightServers.Daemon.Helpers;
|
||||
|
||||
public partial class SystemMetrics
|
||||
{
|
||||
public record DiskInfo(
|
||||
string MountPoint,
|
||||
string Device,
|
||||
string FileSystem,
|
||||
long TotalBytes,
|
||||
long UsedBytes,
|
||||
long FreeBytes,
|
||||
double UsedPercent,
|
||||
long InodesTotal,
|
||||
long InodesUsed,
|
||||
long InodesFree,
|
||||
double InodesUsedPercent
|
||||
);
|
||||
|
||||
private static readonly HashSet<string> IgnoredFileSystems = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"overlay", "aufs", // Container image layers
|
||||
"tmpfs", "devtmpfs", "ramfs", // RAM-backed virtual filesystems
|
||||
"sysfs", "proc", "devpts", // Kernel virtual filesystems
|
||||
"cgroup", "cgroup2",
|
||||
"pstore", "securityfs", "debugfs", "tracefs",
|
||||
"mqueue", "hugetlbfs", "fusectl", "configfs",
|
||||
"binfmt_misc", "nsfs", "rpc_pipefs",
|
||||
"squashfs", // Snap package loop mounts
|
||||
};
|
||||
|
||||
private static readonly string[] IgnoredMountPrefixes =
|
||||
{
|
||||
"/sys",
|
||||
"/proc",
|
||||
"/dev",
|
||||
"/run/docker",
|
||||
"/var/lib/docker",
|
||||
"/var/lib/containers",
|
||||
"/boot", "/boot/efi"
|
||||
};
|
||||
|
||||
private static async Task<IReadOnlyList<DiskInfo>> ReadDisksAsync()
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync("/proc/mounts");
|
||||
var results = new List<DiskInfo>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 4) continue;
|
||||
|
||||
var device = parts[0];
|
||||
var mountPoint = UnescapeOctal(parts[1]);
|
||||
var fsType = parts[2];
|
||||
|
||||
if (IgnoredFileSystems.Contains(fsType)) continue;
|
||||
if (IgnoredMountPrefixes.Any(p => mountPoint.StartsWith(p, StringComparison.Ordinal))) continue;
|
||||
if (device == "none" || device.StartsWith("//", StringComparison.Ordinal)) continue;
|
||||
|
||||
var disk = ReadSingleDisk(device, mountPoint, fsType);
|
||||
|
||||
if (disk is not null)
|
||||
results.Add(disk);
|
||||
}
|
||||
|
||||
return results
|
||||
.GroupBy(d => d.Device)
|
||||
.Select(g => g.OrderBy(d => d.MountPoint.Length).First())
|
||||
.OrderBy(d => d.MountPoint)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static DiskInfo? ReadSingleDisk(string device, string mountPoint, string fsType)
|
||||
{
|
||||
if (NativeMethods.StatVfs(mountPoint, out var st) != 0)
|
||||
{
|
||||
var errno = Marshal.GetLastPInvokeError();
|
||||
Console.WriteLine($"statvfs({mountPoint}) failed: errno {errno} ({new Win32Exception(errno).Message})");
|
||||
return null;
|
||||
}
|
||||
|
||||
var blockSize = (long)st.frsize;
|
||||
var totalBytes = (long)st.blocks * blockSize;
|
||||
var freeBytes = (long)st.bavail * blockSize;
|
||||
|
||||
if (totalBytes <= 0) return null;
|
||||
|
||||
var usedBytes = totalBytes - ((long)st.bfree * blockSize);
|
||||
var usedPct = Math.Round((double)usedBytes / totalBytes * 100.0, 2);
|
||||
|
||||
long inodesTotal, inodesUsed, inodesFree;
|
||||
double inodePct;
|
||||
|
||||
if (st.files == 0)
|
||||
{
|
||||
// Filesystem doesn't expose inode counts (FAT, exFAT, NTFS via ntfs-3g, etc.)
|
||||
inodesTotal = inodesUsed = inodesFree = -1;
|
||||
inodePct = -1.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
inodesTotal = (long)st.files;
|
||||
inodesFree = (long)st.ffree;
|
||||
inodesUsed = inodesTotal - inodesFree;
|
||||
inodePct = Math.Round((double)inodesUsed / inodesTotal * 100.0, 2);
|
||||
}
|
||||
|
||||
return new DiskInfo(
|
||||
MountPoint: mountPoint,
|
||||
Device: device,
|
||||
FileSystem: fsType,
|
||||
TotalBytes: totalBytes,
|
||||
UsedBytes: usedBytes,
|
||||
FreeBytes: freeBytes,
|
||||
UsedPercent: usedPct,
|
||||
InodesTotal: inodesTotal,
|
||||
InodesUsed: inodesUsed,
|
||||
InodesFree: inodesFree,
|
||||
InodesUsedPercent: inodePct
|
||||
);
|
||||
}
|
||||
|
||||
private static string UnescapeOctal(string s)
|
||||
{
|
||||
if (!s.Contains('\\')) return s;
|
||||
|
||||
var sb = new System.Text.StringBuilder(s.Length);
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '\\' && i + 3 < s.Length
|
||||
&& s[i + 1] is >= '0' and <= '7'
|
||||
&& s[i + 2] is >= '0' and <= '7'
|
||||
&& s[i + 3] is >= '0' and <= '7')
|
||||
{
|
||||
sb.Append((char)((s[i + 1] - '0') * 64 + (s[i + 2] - '0') * 8 + (s[i + 3] - '0')));
|
||||
i += 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(s[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
59
MoonlightServers.Daemon/Helpers/SystemMetrics.Memory.cs
Normal file
59
MoonlightServers.Daemon/Helpers/SystemMetrics.Memory.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace MoonlightServers.Daemon.Helpers;
|
||||
|
||||
public partial class SystemMetrics
|
||||
{
|
||||
/// <summary>Memory figures derived from <c>/proc/meminfo</c>.</summary>
|
||||
/// <param name="TotalBytes">Physical RAM installed.</param>
|
||||
/// <param name="UsedBytes">RAM actively in use (<c>Total - Available</c>).</param>
|
||||
/// <param name="FreeBytes">Completely unallocated RAM.</param>
|
||||
/// <param name="CachedBytes">Page cache + reclaimable slab — matches the <c>cached</c> column in <c>free -h</c>.</param>
|
||||
/// <param name="BuffersBytes">Kernel I/O buffer memory.</param>
|
||||
/// <param name="AvailableBytes">Estimated RAM available for new allocations without swapping.</param>
|
||||
/// <param name="UsedPercent">UsedBytes / TotalBytes as a percentage (0–100).</param>
|
||||
public record MemoryInfo(
|
||||
long TotalBytes,
|
||||
long UsedBytes,
|
||||
long FreeBytes,
|
||||
long CachedBytes,
|
||||
long BuffersBytes,
|
||||
long AvailableBytes,
|
||||
double UsedPercent
|
||||
);
|
||||
|
||||
// Memory — /proc/meminfo
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>/proc/meminfo</c> into a <see cref="MemoryInfo"/> record.
|
||||
/// The <c>CachedBytes</c> field is computed as
|
||||
/// <c>Cached + SReclaimable - Shmem</c> to match the value shown by <c>free -h</c>.
|
||||
/// </summary>
|
||||
private static async Task<MemoryInfo> ReadMemoryAsync()
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync("/proc/meminfo");
|
||||
var map = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon < 0)
|
||||
continue;
|
||||
|
||||
var key = line[..colon].Trim();
|
||||
var val = line[(colon + 1)..].Trim().Split(' ')[0]; // Strip "kB" suffix.
|
||||
if (long.TryParse(val, out var kb))
|
||||
map[key] = kb * 1024L;
|
||||
}
|
||||
|
||||
var total = map.GetValueOrDefault("MemTotal");
|
||||
var available = map.GetValueOrDefault("MemAvailable");
|
||||
var free = map.GetValueOrDefault("MemFree");
|
||||
var buffers = map.GetValueOrDefault("Buffers");
|
||||
var cached = map.GetValueOrDefault("Cached")
|
||||
+ map.GetValueOrDefault("SReclaimable")
|
||||
- map.GetValueOrDefault("Shmem");
|
||||
var used = total - available;
|
||||
var usedPct = total > 0 ? Math.Round((double)used / total * 100.0, 2) : 0.0;
|
||||
|
||||
return new MemoryInfo(total, used, free, cached, buffers, available, usedPct);
|
||||
}
|
||||
}
|
||||
101
MoonlightServers.Daemon/Helpers/SystemMetrics.Network.cs
Normal file
101
MoonlightServers.Daemon/Helpers/SystemMetrics.Network.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
namespace MoonlightServers.Daemon.Helpers;
|
||||
|
||||
public partial class SystemMetrics
|
||||
{
|
||||
/// <summary>Network throughput for a single interface, computed between two samples.</summary>
|
||||
/// <param name="Name">Interface name, e.g. <c>eth0</c>, <c>ens3</c>.</param>
|
||||
/// <param name="RxBytesPerSec">Received bytes per second.</param>
|
||||
/// <param name="TxBytesPerSec">Transmitted bytes per second.</param>
|
||||
/// <param name="RxPacketsPerSec">Received packets per second.</param>
|
||||
/// <param name="TxPacketsPerSec">Transmitted packets per second.</param>
|
||||
/// <param name="RxErrors">Cumulative receive error count (not a rate).</param>
|
||||
/// <param name="TxErrors">Cumulative transmit error count (not a rate).</param>
|
||||
public record NetworkInterfaceInfo(
|
||||
string Name,
|
||||
long RxBytesPerSec,
|
||||
long TxBytesPerSec,
|
||||
long RxPacketsPerSec,
|
||||
long TxPacketsPerSec,
|
||||
long RxErrors,
|
||||
long TxErrors
|
||||
);
|
||||
|
||||
// Network
|
||||
private record RawNetLine(
|
||||
string Iface,
|
||||
long RxBytes,
|
||||
long RxPackets,
|
||||
long RxErrors,
|
||||
long TxBytes,
|
||||
long TxPackets,
|
||||
long TxErrors
|
||||
);
|
||||
|
||||
private static async Task<List<RawNetLine>> ReadRawNetStatsAsync()
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync("/proc/net/dev");
|
||||
var result = new List<RawNetLine>();
|
||||
|
||||
// The first two lines are the column-header banner.
|
||||
foreach (var line in lines.Skip(2))
|
||||
{
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon < 0)
|
||||
continue;
|
||||
|
||||
var iface = line[..colon].Trim();
|
||||
|
||||
// Skip loopback and ephemeral veth pairs created by container runtimes.
|
||||
if (iface == "lo" || iface.StartsWith("veth", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var f = line[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (f.Length < 16)
|
||||
continue;
|
||||
|
||||
// Column layout (after the colon, 0-based):
|
||||
// RX: [0]bytes [1]packets [2]errs [3]drop [4]fifo [5]frame [6]compressed [7]multicast
|
||||
// TX: [8]bytes [9]packets [10]errs [11]drop [12]fifo [13]colls [14]carrier [15]compressed
|
||||
result.Add(new RawNetLine(
|
||||
Iface: iface,
|
||||
RxBytes: long.Parse(f[0]),
|
||||
RxPackets: long.Parse(f[1]),
|
||||
RxErrors: long.Parse(f[2]),
|
||||
TxBytes: long.Parse(f[8]),
|
||||
TxPackets: long.Parse(f[9]),
|
||||
TxErrors: long.Parse(f[10])
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NetworkInterfaceInfo> ComputeNetworkRates(
|
||||
List<RawNetLine> s1,
|
||||
List<RawNetLine> s2,
|
||||
double intervalSecs
|
||||
)
|
||||
{
|
||||
var prev = s1.ToDictionary(x => x.Iface);
|
||||
var result = new List<NetworkInterfaceInfo>();
|
||||
var div = intervalSecs > 0 ? intervalSecs : 1.0;
|
||||
|
||||
foreach (var cur in s2)
|
||||
{
|
||||
if (!prev.TryGetValue(cur.Iface, out var p))
|
||||
continue;
|
||||
|
||||
result.Add(new NetworkInterfaceInfo(
|
||||
Name: cur.Iface,
|
||||
RxBytesPerSec: (long)((cur.RxBytes - p.RxBytes) / div),
|
||||
TxBytesPerSec: (long)((cur.TxBytes - p.TxBytes) / div),
|
||||
RxPacketsPerSec: (long)((cur.RxPackets - p.RxPackets) / div),
|
||||
TxPacketsPerSec: (long)((cur.TxPackets - p.TxPackets) / div),
|
||||
RxErrors: cur.RxErrors,
|
||||
TxErrors: cur.TxErrors
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
59
MoonlightServers.Daemon/Helpers/SystemMetrics.cs
Normal file
59
MoonlightServers.Daemon/Helpers/SystemMetrics.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace MoonlightServers.Daemon.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads Linux system metrics directly from the <c>/proc</c> and <c>/sys</c>
|
||||
/// pseudo-filesystems
|
||||
/// </summary>
|
||||
public static partial class SystemMetrics
|
||||
{
|
||||
public record SystemSnapshot(
|
||||
CpuSnapshot Cpu,
|
||||
MemoryInfo Memory,
|
||||
IReadOnlyList<DiskInfo> Disks,
|
||||
IReadOnlyList<NetworkInterfaceInfo> Network,
|
||||
TimeSpan Uptime
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Collects a full system snapshot. The method waits <paramref name="sampleIntervalMs"/>
|
||||
/// milliseconds between the two samples required to compute CPU and network rates.
|
||||
/// All other reads happen in parallel during the second sampling window.
|
||||
/// </summary>
|
||||
/// <param name="sampleIntervalMs">
|
||||
/// Interval between rate-measurement samples in milliseconds.
|
||||
/// Larger values yield smoother CPU and network averages. Defaults to <c>500</c>.
|
||||
/// </param>
|
||||
/// <returns>A fully populated <see cref="SystemSnapshot"/>.</returns>
|
||||
public static async Task<SystemSnapshot> ReadAllAsync(int sampleIntervalMs = 500)
|
||||
{
|
||||
// First samples — must complete before the delay.
|
||||
var cpuSample1Task = ReadRawCpuStatsAsync();
|
||||
var netSample1Task = ReadRawNetStatsAsync();
|
||||
await Task.WhenAll(cpuSample1Task, netSample1Task);
|
||||
|
||||
await Task.Delay(sampleIntervalMs);
|
||||
|
||||
// Second samples + all independent reads run concurrently.
|
||||
var cpuSample2Task = ReadRawCpuStatsAsync();
|
||||
var netSample2Task = ReadRawNetStatsAsync();
|
||||
var memTask = ReadMemoryAsync();
|
||||
var diskTask = ReadDisksAsync();
|
||||
var uptimeTask = ReadUptimeAsync();
|
||||
var cpuNameTask = ReadCpuModelNameAsync();
|
||||
|
||||
await Task.WhenAll(cpuSample2Task, netSample2Task, memTask, diskTask, uptimeTask, cpuNameTask);
|
||||
|
||||
var cpu = ComputeCpuUsage(cpuNameTask.Result, cpuSample1Task.Result, cpuSample2Task.Result);
|
||||
var network = ComputeNetworkRates(netSample1Task.Result, netSample2Task.Result, sampleIntervalMs / 1000.0);
|
||||
|
||||
return new SystemSnapshot(cpu, memTask.Result, diskTask.Result, network, uptimeTask.Result);
|
||||
}
|
||||
|
||||
// Uptime
|
||||
private static async Task<TimeSpan> ReadUptimeAsync()
|
||||
{
|
||||
var text = await File.ReadAllTextAsync("/proc/uptime");
|
||||
var seconds = double.Parse(text.Split(' ')[0], System.Globalization.CultureInfo.InvariantCulture);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user