Implemented node system statistics

This commit is contained in:
2026-03-21 18:21:09 +00:00
parent ba5e364c05
commit 6d447a0ff9
28 changed files with 1402 additions and 156 deletions

View 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);
}

View 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);
}
}
}

View 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();
}
}

View 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 (0100).</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);
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.DaemonShared.Http.Daemon;
namespace MoonlightServers.Daemon.Http.Controllers;
[Authorize]
[ApiController]
[Route("api/system")]
public class SystemController : Controller
{
[HttpGet("statistics")]
public async Task<ActionResult<SystemStatisticsDto>> GetStatisticsAsync()
{
var snapshot = await SystemMetrics.ReadAllAsync();
var statistics = SystemStatisticsMapper.ToDto(snapshot);
return statistics;
}
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.DaemonShared.Http.Daemon;
using Riok.Mapperly.Abstractions;
namespace MoonlightServers.Daemon.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class SystemStatisticsMapper
{
public static partial SystemStatisticsDto ToDto(SystemMetrics.SystemSnapshot snapshot);
}

View File

@@ -4,10 +4,12 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Docker.DotNet.Enhanced" Version="3.132.0" />
<PackageReference Include="Riok.Mapperly" Version="5.0.0-next.2" />
</ItemGroup>
<ItemGroup>
@@ -26,6 +28,18 @@
<Compile Update="ServerSystem\Server.Update.cs">
<DependentUpon>Server.cs</DependentUpon>
</Compile>
<Compile Update="Helpers\SystemMetrics.Cpu.cs">
<DependentUpon>SystemMetrics.cs</DependentUpon>
</Compile>
<Compile Update="Helpers\SystemMetrics.Memory.cs">
<DependentUpon>SystemMetrics.cs</DependentUpon>
</Compile>
<Compile Update="Helpers\SystemMetrics.Network.cs">
<DependentUpon>SystemMetrics.cs</DependentUpon>
</Compile>
<Compile Update="Helpers\SystemMetrics.Disk.cs">
<DependentUpon>SystemMetrics.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>