diff --git a/Hosts/MoonlightServers.Frontend.Host/wwwroot/index.html b/Hosts/MoonlightServers.Frontend.Host/wwwroot/index.html index dfb9733..71ee7c2 100644 --- a/Hosts/MoonlightServers.Frontend.Host/wwwroot/index.html +++ b/Hosts/MoonlightServers.Frontend.Host/wwwroot/index.html @@ -102,6 +102,9 @@ + + + diff --git a/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs b/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs index ee1f036..4f0db6b 100644 --- a/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs +++ b/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using MoonlightServers.Api.Infrastructure.Database.Entities; +using MoonlightServers.DaemonShared.Http.Daemon; using MoonlightServers.Shared.Admin.Nodes; using Riok.Mapperly.Abstractions; @@ -14,4 +15,6 @@ public static partial class NodeMapper public static partial IQueryable ProjectToDto(this IQueryable nodes); public static partial Node ToEntity(CreateNodeDto dto); public static partial void Merge([MappingTarget] Node node, UpdateNodeDto dto); + + public static partial NodeStatisticsDto ToDto(SystemStatisticsDto dto); } \ No newline at end of file diff --git a/MoonlightServers.Api/Admin/Nodes/NodeService.cs b/MoonlightServers.Api/Admin/Nodes/NodeService.cs index f5361cc..61ffef3 100644 --- a/MoonlightServers.Api/Admin/Nodes/NodeService.cs +++ b/MoonlightServers.Api/Admin/Nodes/NodeService.cs @@ -54,6 +54,19 @@ public class NodeService } } + public async Task GetStatisticsAsync(Node node) + { + var client = ClientFactory.CreateClient(); + + var request = CreateBaseRequest(node, HttpMethod.Get, "api/system/statistics"); + + var response = await client.SendAsync(request); + + await EnsureSuccessAsync(response); + + return (await response.Content.ReadFromJsonAsync(SerializationContext.Default.Options))!; + } + private static HttpRequestMessage CreateBaseRequest( Node node, [StringSyntax(StringSyntaxAttribute.Uri)] diff --git a/MoonlightServers.Api/Admin/Nodes/StatisticsController.cs b/MoonlightServers.Api/Admin/Nodes/StatisticsController.cs new file mode 100644 index 0000000..178288d --- /dev/null +++ b/MoonlightServers.Api/Admin/Nodes/StatisticsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoonlightServers.Api.Infrastructure.Database; +using MoonlightServers.Api.Infrastructure.Database.Entities; +using MoonlightServers.Shared; +using MoonlightServers.Shared.Admin.Nodes; + +namespace MoonlightServers.Api.Admin.Nodes; + +[ApiController] +[Authorize(Policy = Permissions.Nodes.View)] +[Route("api/admin/servers/nodes/{id:int}/statistics")] +public class StatisticsController : Controller +{ + private readonly NodeService NodeService; + private readonly DatabaseRepository NodeRepository; + + public StatisticsController(NodeService nodeService, DatabaseRepository nodeRepository) + { + NodeService = nodeService; + NodeRepository = nodeRepository; + } + + [HttpGet] + public async Task> GetAsync([FromRoute] int id) + { + var node = await NodeRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (node == null) + return Problem("No node with this id found", statusCode: 404); + + var statistics = await NodeService.GetStatisticsAsync(node); + var dto = NodeMapper.ToDto(statistics); + + return dto; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/NativeMethods.cs b/MoonlightServers.Daemon/Helpers/NativeMethods.cs new file mode 100644 index 0000000..dc0af06 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/NativeMethods.cs @@ -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); +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/SystemMetrics.Cpu.cs b/MoonlightServers.Daemon/Helpers/SystemMetrics.Cpu.cs new file mode 100644 index 0000000..cc7741d --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/SystemMetrics.Cpu.cs @@ -0,0 +1,76 @@ +namespace MoonlightServers.Daemon.Helpers; + +public partial class SystemMetrics +{ + public record CpuSnapshot( + string ModelName, + double TotalUsagePercent, + IReadOnlyList CoreUsagePercents + ); + + private record RawCpuLine( + long User, + long Nice, + long System, + long Idle, + long Iowait, + long Irq, + long Softirq, + long Steal + ); + + private static async Task> ReadRawCpuStatsAsync() + { + var lines = await File.ReadAllLinesAsync("/proc/stat"); + var result = new List(); + + 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 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 s1, List 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); + } + } + +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/SystemMetrics.Disk.cs b/MoonlightServers.Daemon/Helpers/SystemMetrics.Disk.cs new file mode 100644 index 0000000..06f2dc6 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/SystemMetrics.Disk.cs @@ -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 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> ReadDisksAsync() + { + var lines = await File.ReadAllLinesAsync("/proc/mounts"); + var results = new List(); + + 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(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/SystemMetrics.Memory.cs b/MoonlightServers.Daemon/Helpers/SystemMetrics.Memory.cs new file mode 100644 index 0000000..c5dc576 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/SystemMetrics.Memory.cs @@ -0,0 +1,59 @@ +namespace MoonlightServers.Daemon.Helpers; + +public partial class SystemMetrics +{ + /// Memory figures derived from /proc/meminfo. + /// Physical RAM installed. + /// RAM actively in use (Total - Available). + /// Completely unallocated RAM. + /// Page cache + reclaimable slab — matches the cached column in free -h. + /// Kernel I/O buffer memory. + /// Estimated RAM available for new allocations without swapping. + /// UsedBytes / TotalBytes as a percentage (0–100). + public record MemoryInfo( + long TotalBytes, + long UsedBytes, + long FreeBytes, + long CachedBytes, + long BuffersBytes, + long AvailableBytes, + double UsedPercent + ); + + // Memory — /proc/meminfo + + /// + /// Parses /proc/meminfo into a record. + /// The CachedBytes field is computed as + /// Cached + SReclaimable - Shmem to match the value shown by free -h. + /// + private static async Task ReadMemoryAsync() + { + var lines = await File.ReadAllLinesAsync("/proc/meminfo"); + var map = new Dictionary(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); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/SystemMetrics.Network.cs b/MoonlightServers.Daemon/Helpers/SystemMetrics.Network.cs new file mode 100644 index 0000000..07288a6 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/SystemMetrics.Network.cs @@ -0,0 +1,101 @@ +namespace MoonlightServers.Daemon.Helpers; + +public partial class SystemMetrics +{ + /// Network throughput for a single interface, computed between two samples. + /// Interface name, e.g. eth0, ens3. + /// Received bytes per second. + /// Transmitted bytes per second. + /// Received packets per second. + /// Transmitted packets per second. + /// Cumulative receive error count (not a rate). + /// Cumulative transmit error count (not a rate). + 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> ReadRawNetStatsAsync() + { + var lines = await File.ReadAllLinesAsync("/proc/net/dev"); + var result = new List(); + + // 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 ComputeNetworkRates( + List s1, + List s2, + double intervalSecs + ) + { + var prev = s1.ToDictionary(x => x.Iface); + var result = new List(); + 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; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/SystemMetrics.cs b/MoonlightServers.Daemon/Helpers/SystemMetrics.cs new file mode 100644 index 0000000..11caacd --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/SystemMetrics.cs @@ -0,0 +1,59 @@ +namespace MoonlightServers.Daemon.Helpers; + +/// +/// Reads Linux system metrics directly from the /proc and /sys +/// pseudo-filesystems +/// +public static partial class SystemMetrics +{ + public record SystemSnapshot( + CpuSnapshot Cpu, + MemoryInfo Memory, + IReadOnlyList Disks, + IReadOnlyList Network, + TimeSpan Uptime + ); + + /// + /// Collects a full system snapshot. The method waits + /// milliseconds between the two samples required to compute CPU and network rates. + /// All other reads happen in parallel during the second sampling window. + /// + /// + /// Interval between rate-measurement samples in milliseconds. + /// Larger values yield smoother CPU and network averages. Defaults to 500. + /// + /// A fully populated . + public static async Task 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 ReadUptimeAsync() + { + var text = await File.ReadAllTextAsync("/proc/uptime"); + var seconds = double.Parse(text.Split(' ')[0], System.Globalization.CultureInfo.InvariantCulture); + return TimeSpan.FromSeconds(seconds); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/SystemController.cs b/MoonlightServers.Daemon/Http/Controllers/SystemController.cs new file mode 100644 index 0000000..ee40581 --- /dev/null +++ b/MoonlightServers.Daemon/Http/Controllers/SystemController.cs @@ -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> GetStatisticsAsync() + { + var snapshot = await SystemMetrics.ReadAllAsync(); + var statistics = SystemStatisticsMapper.ToDto(snapshot); + + return statistics; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Mappers/SystemStatisticsMapper.cs b/MoonlightServers.Daemon/Mappers/SystemStatisticsMapper.cs new file mode 100644 index 0000000..64cf17f --- /dev/null +++ b/MoonlightServers.Daemon/Mappers/SystemStatisticsMapper.cs @@ -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); +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj index 1541759..fa8cc99 100644 --- a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj +++ b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj @@ -4,10 +4,12 @@ net10.0 enable enable + true + @@ -26,6 +28,18 @@ Server.cs + + SystemMetrics.cs + + + SystemMetrics.cs + + + SystemMetrics.cs + + + SystemMetrics.cs + diff --git a/MoonlightServers.DaemonShared/Http/Daemon/SystemStatisticsDto.cs b/MoonlightServers.DaemonShared/Http/Daemon/SystemStatisticsDto.cs new file mode 100644 index 0000000..4f4c600 --- /dev/null +++ b/MoonlightServers.DaemonShared/Http/Daemon/SystemStatisticsDto.cs @@ -0,0 +1,49 @@ +namespace MoonlightServers.DaemonShared.Http.Daemon; + +public record SystemStatisticsDto( + CpuSnapshotDto Cpu, + MemoryInfoDto Memory, + IReadOnlyList Disks, + IReadOnlyList Network, + TimeSpan Uptime +); + +public record CpuSnapshotDto( + string ModelName, + double TotalUsagePercent, + IReadOnlyList CoreUsagePercents +); + +public record MemoryInfoDto( + long TotalBytes, + long UsedBytes, + long FreeBytes, + long CachedBytes, + long BuffersBytes, + long AvailableBytes, + double UsedPercent +); + +public record DiskInfoDto( + string MountPoint, + string Device, + string FileSystem, + long TotalBytes, + long UsedBytes, + long FreeBytes, + double UsedPercent, + long InodesTotal, + long InodesUsed, + long InodesFree, + double InodesUsedPercent +); + +public record NetworkInterfaceInfoDto( + string Name, + long RxBytesPerSec, + long TxBytesPerSec, + long RxPacketsPerSec, + long TxPacketsPerSec, + long RxErrors, + long TxErrors +); \ No newline at end of file diff --git a/MoonlightServers.DaemonShared/Http/SerializationContext.cs b/MoonlightServers.DaemonShared/Http/SerializationContext.cs index a002a25..65e85d0 100644 --- a/MoonlightServers.DaemonShared/Http/SerializationContext.cs +++ b/MoonlightServers.DaemonShared/Http/SerializationContext.cs @@ -5,6 +5,7 @@ using MoonlightServers.DaemonShared.Http.Daemon; namespace MoonlightServers.DaemonShared.Http; [JsonSerializable(typeof(HealthDto))] +[JsonSerializable(typeof(SystemStatisticsDto))] [JsonSerializable(typeof(ProblemDetails))] [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] diff --git a/MoonlightServers.Frontend/Admin/Nodes/Edit.razor b/MoonlightServers.Frontend/Admin/Nodes/Edit.razor deleted file mode 100644 index 54f28fe..0000000 --- a/MoonlightServers.Frontend/Admin/Nodes/Edit.razor +++ /dev/null @@ -1,154 +0,0 @@ -@page "/admin/servers/nodes/{Id:int}" - -@using System.Net -@using LucideBlazor -@using Moonlight.Frontend.Infrastructure.Helpers -@using MoonlightServers.Shared -@using MoonlightServers.Shared.Admin.Nodes -@using ShadcnBlazor.Buttons -@using ShadcnBlazor.Cards -@using ShadcnBlazor.Emptys -@using ShadcnBlazor.Extras.Common -@using ShadcnBlazor.Extras.Forms -@using ShadcnBlazor.Extras.Toasts -@using ShadcnBlazor.Fields -@using ShadcnBlazor.Inputs - -@inject HttpClient HttpClient -@inject NavigationManager Navigation -@inject ToastService ToastService - - - @if (Node == null) - { - - - - - - Node not found - - A node with this id cannot be found - - - - } - else - { - - -
-
-

Update Node

-
- Update node @Node.Name -
-
-
- - - - Continue - -
-
- -
- - - - - - - -
- -
- - Name - - - - - HTTP Endpoint - - -
-
-
-
-
-
-
- -
- } -
- -@code -{ - [Parameter] public int Id { get; set; } - - private UpdateNodeDto Request; - private NodeDto? Node; - - private async Task LoadAsync(LazyLoader _) - { - var response = await HttpClient.GetAsync($"api/admin/servers/nodes/{Id}"); - - if (!response.IsSuccessStatusCode) - { - if(response.StatusCode == HttpStatusCode.NotFound) - return; - - response.EnsureSuccessStatusCode(); - return; - } - - Node = await response.Content.ReadFromJsonAsync(SerializationContext.Default.Options); - - if(Node == null) - return; - - Request = new UpdateNodeDto() - { - Name = Node.Name, - HttpEndpointUrl = Node.HttpEndpointUrl - }; - } - - private async Task OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore) - { - var response = await HttpClient.PutAsJsonAsync( - $"/api/admin/servers/nodes/{Id}", - Request, - SerializationContext.Default.Options - ); - - if (!response.IsSuccessStatusCode) - { - await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore); - return false; - } - - await ToastService.SuccessAsync( - "Node Update", - $"Successfully updated node {Request.Name}" - ); - - Navigation.NavigateTo("/admin/servers?tab=nodes"); - return true; - } -} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor b/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor index cb20afe..c24e1c9 100644 --- a/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor +++ b/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor @@ -1,4 +1,5 @@ @using Microsoft.Extensions.Logging +@using MoonlightServers.Shared @using MoonlightServers.Shared.Admin.Nodes @using ShadcnBlazor.Tooltips @@ -55,7 +56,7 @@ else try { - var result = await HttpClient.GetFromJsonAsync($"api/admin/servers/nodes/{Node.Id}/health"); + var result = await HttpClient.GetFromJsonAsync($"api/admin/servers/nodes/{Node.Id}/health", SerializationContext.Default.Options); if(result == null) return; diff --git a/MoonlightServers.Frontend/Admin/Nodes/SettingsTab.razor b/MoonlightServers.Frontend/Admin/Nodes/SettingsTab.razor new file mode 100644 index 0000000..00c583e --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/SettingsTab.razor @@ -0,0 +1,85 @@ +@using Moonlight.Frontend.Infrastructure.Helpers +@using MoonlightServers.Shared +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Extras.Forms +@using ShadcnBlazor.Extras.Toasts +@using ShadcnBlazor.Fields +@using ShadcnBlazor.Inputs + +@inject HttpClient HttpClient +@inject ToastService ToastService + + + + + + + + + +
+ +
+ + Name + + + + + HTTP Endpoint + + +
+
+
+
+
+ + Save changes + +
+
+ +@code +{ + [Parameter, EditorRequired] public NodeDto Node { get; set; } + + private UpdateNodeDto Request; + + protected override void OnInitialized() + { + Request = new UpdateNodeDto() + { + Name = Node.Name, + HttpEndpointUrl = Node.HttpEndpointUrl + }; + } + + private async Task OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore) + { + var response = await HttpClient.PutAsJsonAsync( + $"/api/admin/servers/nodes/{Node.Id}", + Request, + SerializationContext.Default.Options + ); + + if (!response.IsSuccessStatusCode) + { + await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore); + return false; + } + + await ToastService.SuccessAsync( + "Node Update", + $"Successfully updated node {Request.Name}" + ); + + return true; + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Admin/Nodes/StatisticsTab.razor b/MoonlightServers.Frontend/Admin/Nodes/StatisticsTab.razor new file mode 100644 index 0000000..c9ba736 --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/StatisticsTab.razor @@ -0,0 +1,301 @@ +@using LucideBlazor +@using MoonlightServers.Frontend.Infrastructure.Helpers +@using MoonlightServers.Frontend.Shared +@using MoonlightServers.Shared +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Emptys +@using ShadcnBlazor.Spinners +@using ShadcnBlazor.Progresses + +@inject HttpClient HttpClient + +@implements IAsyncDisposable + +

Overview

+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + @if (HasLoaded && StatisticsDto != null) + { + + Uptime + @Formatter.FormatDuration(StatisticsDto.Uptime) + + } + else + { + + + + } + + + + @if (HasLoaded && HealthDto != null) + { + + Health Status + + @if (HealthDto.IsHealthy) + { + Healthy + } + else + { + Unhealthy + } + + + } + else + { + + + + } + +
+ +

Details

+ +
+ + + + System Details + Details over your general system configuration + + + @if (StatisticsDto == null) + { + + + + + + No details + + No details about your system found + + + + } + else + { +
+
+ CPU Model + + @StatisticsDto.Cpu.ModelName + +
+
+ Total Memory + + @Formatter.FormatSize(StatisticsDto.Memory.TotalBytes) + +
+
+ Total Disk Space + + @{ + var totalDiskSpace = StatisticsDto + .Disks + .DistinctBy(x => x.Device) + .Sum(x => x.TotalBytes); + } + + @Formatter.FormatSize(totalDiskSpace) + +
+
+ } +
+
+ + + + Disk Details + Details over all your mounted disks + + + @if (StatisticsDto == null) + { + + + + + + No details + + No details about disk and their usage found + + + + } + else + { +
+ @foreach (var disk in StatisticsDto.Disks) + { +
+
+
+ + @disk.MountPoint + + + @disk.FileSystem + + + @disk.Device + +
+ + @Formatter.FormatSize(disk.UsedBytes) / @Formatter.FormatSize(disk.TotalBytes) + +
+ + +
+ } +
+ } +
+
+
+ +@code +{ + [Parameter] public NodeDto Node { get; set; } + + private NodeHealthDto? HealthDto; + private NodeStatisticsDto? StatisticsDto; + + private bool HasLoaded; + + private RealtimeChart? CpuChart; + + private RealtimeChart? NetworkInChart; + + private RealtimeChart? NetworkOutChart; + + private RealtimeChart? MemoryChart; + private Timer? Timer; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + HealthDto = await HttpClient.GetFromJsonAsync( + $"api/admin/servers/nodes/{Node.Id}/health", + SerializationContext.Default.Options + ); + + if (HealthDto is { IsHealthy: true }) + { + StatisticsDto = await HttpClient.GetFromJsonAsync( + $"api/admin/servers/nodes/{Node.Id}/statistics", + SerializationContext.Default.Options + ); + } + + HasLoaded = true; + await InvokeAsync(StateHasChanged); + + Timer = new Timer(RefreshCallbackAsync, null, TimeSpan.Zero, TimeSpan.FromSeconds(1)); + } + + private async void RefreshCallbackAsync(object? state) + { + try + { + StatisticsDto = await HttpClient.GetFromJsonAsync( + $"api/admin/servers/nodes/{Node.Id}/statistics", + SerializationContext.Default.Options + ); + + if (StatisticsDto == null) return; + + if (CpuChart != null) + await CpuChart.PushAsync(StatisticsDto.Cpu.TotalUsagePercent); + + if (MemoryChart != null) + await MemoryChart.PushAsync(new MemoryDataPoint(StatisticsDto.Memory.UsedBytes, StatisticsDto.Memory.UsedPercent)); + + if (NetworkInChart != null && NetworkOutChart != null) + { + var networkInterface = StatisticsDto + .Network + .FirstOrDefault(x => x.Name.StartsWith("eth")); + + if (networkInterface == null) + { + networkInterface = StatisticsDto + .Network + .FirstOrDefault(x => x.Name.StartsWith("en")); + } + + if (networkInterface == null) + return; + + await NetworkInChart.PushAsync(networkInterface.RxBytesPerSec); + await NetworkOutChart.PushAsync(networkInterface.TxBytesPerSec); + } + + await InvokeAsync(StateHasChanged); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + + public async ValueTask DisposeAsync() + { + if (Timer != null) + await Timer.DisposeAsync(); + + if (CpuChart != null) + await CpuChart.DisposeAsync(); + } + + private record MemoryDataPoint(long UsedMemory, double Percent); +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Admin/Nodes/View.razor b/MoonlightServers.Frontend/Admin/Nodes/View.razor new file mode 100644 index 0000000..56bf7d9 --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/View.razor @@ -0,0 +1,90 @@ +@page "/admin/servers/nodes/{Id:int}" + +@using LucideBlazor +@using MoonlightServers.Shared +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.Emptys +@using ShadcnBlazor.Extras.Common +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Tab + +@inject HttpClient HttpClient + +@attribute [Authorize(Policy = Permissions.Nodes.View)] + + + @if (Dto == null) + { + + + + + + Node not found + + A node with this id cannot be found + + + + } + else + { +
+
+

@Dto.Name

+
+ View details for @Dto.Name +
+
+
+ +
+
+ +
+ + + + + Statistics + + + + Settings + + + + + + + + + + + + +
+ } +
+ +@code +{ + [Parameter] public int Id { get; set; } + + private NodeDto? Dto; + + private async Task LoadAsync(LazyLoader _) + { + Dto = await HttpClient.GetFromJsonAsync( + $"api/admin/servers/nodes/{Id}", + SerializationContext.Default.Options + ); + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Infrastructure/Helpers/Formatter.cs b/MoonlightServers.Frontend/Infrastructure/Helpers/Formatter.cs new file mode 100644 index 0000000..dcdc913 --- /dev/null +++ b/MoonlightServers.Frontend/Infrastructure/Helpers/Formatter.cs @@ -0,0 +1,46 @@ +namespace MoonlightServers.Frontend.Infrastructure.Helpers; + +internal static class Formatter +{ + internal static string FormatSize(long bytes, double conversionStep = 1024) + { + if (bytes == 0) return "0 B"; + + string[] units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + var unitIndex = 0; + double size = bytes; + + while (size >= conversionStep && unitIndex < units.Length - 1) + { + size /= conversionStep; + unitIndex++; + } + + var decimals = unitIndex == 0 ? 0 : 2; + return $"{Math.Round(size, decimals)} {units[unitIndex]}"; + } + + internal static string FormatDuration(TimeSpan timeSpan) + { + var abs = timeSpan.Duration(); // Handle negative timespans + + if (abs.TotalSeconds < 1) + return $"{abs.TotalMilliseconds:F0}ms"; + + if (abs.TotalMinutes < 1) + return $"{abs.TotalSeconds:F1}s"; + + if (abs.TotalHours < 1) + return $"{abs.Minutes}m {abs.Seconds}s"; + + if (abs.TotalDays < 1) + return $"{abs.Hours}h {abs.Minutes}m"; + + if (abs.TotalDays < 365) + return $"{abs.Days}d {abs.Hours}h"; + + var years = (int)(abs.TotalDays / 365); + var days = abs.Days % 365; + return days > 0 ? $"{years}y {days}d" : $"{years}y"; + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Infrastructure/ScriptImports.razor b/MoonlightServers.Frontend/Infrastructure/ScriptImports.razor new file mode 100644 index 0000000..1475536 --- /dev/null +++ b/MoonlightServers.Frontend/Infrastructure/ScriptImports.razor @@ -0,0 +1,5 @@ +@inherits Moonlight.Frontend.Infrastructure.Hooks.LayoutMiddlewareBase + +@ChildContent + + \ No newline at end of file diff --git a/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj b/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj index 8e7d2b9..16b8ffe 100644 --- a/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj +++ b/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj @@ -29,7 +29,6 @@ - diff --git a/MoonlightServers.Frontend/Shared/RealtimeChart.razor b/MoonlightServers.Frontend/Shared/RealtimeChart.razor new file mode 100644 index 0000000..8de2f15 --- /dev/null +++ b/MoonlightServers.Frontend/Shared/RealtimeChart.razor @@ -0,0 +1,93 @@ +@using ShadcnBlazor.Cards + +@inject IJSRuntime JsRuntime + +@implements IAsyncDisposable + +@typeparam T + + + +
+ @if (!string.IsNullOrEmpty(Title)) + { + @Title + } + + @if (CurrentValue != null) + { + @DisplayField.Invoke(CurrentValue) + } + else + { + - + } + +
+ + +
+
+ +@code +{ + [Parameter] public IEnumerable? DefaultItems { get; set; } + [Parameter] public Func DisplayField { get; set; } + [Parameter] public Func ValueField { get; set; } + [Parameter] public string Title { get; set; } + [Parameter] public int Min { get; set; } = 0; + [Parameter] public int Max { get; set; } = 100; + [Parameter] public int VisibleDataPoints { get; set; } = 30; + + [Parameter] public string ClassName { get; set; } + + private string Identifier; + private T? CurrentValue; + private int Counter; + + protected override void OnInitialized() + { + Identifier = $"realtimeChart{GetHashCode()}"; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + var items = DefaultItems?.ToArray() ?? []; + + var labels = items.Select(x => + { + Counter++; + return Counter.ToString(); + }); + + var dataPoints = items.Select(ValueField); + + await JsRuntime.InvokeVoidAsync( + "moonlightServersRealtimeChart.init", + Identifier, + Identifier, + VisibleDataPoints, + Min, + Max, + labels, + dataPoints + ); + } + + public async Task PushAsync(T value) + { + Counter++; + var label = Counter.ToString(); + var dataPoint = ValueField.Invoke(value); + + CurrentValue = value; + + await JsRuntime.InvokeVoidAsync("moonlightServersRealtimeChart.pushValue", Identifier, label, dataPoint); + await InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() + => await JsRuntime.InvokeVoidAsync("moonlightServersRealtimeChart.destroy", Identifier); +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Startup.cs b/MoonlightServers.Frontend/Startup.cs index 176d5df..72b9360 100644 --- a/MoonlightServers.Frontend/Startup.cs +++ b/MoonlightServers.Frontend/Startup.cs @@ -20,6 +20,11 @@ public sealed class Startup : MoonlightPlugin { options.Assemblies.Add(typeof(Startup).Assembly); }); + + builder.Services.Configure(options => + { + options.Add(); + }); } public override void PostBuild(WebAssemblyHost application) diff --git a/MoonlightServers.Frontend/wwwroot/realtimeChart.js b/MoonlightServers.Frontend/wwwroot/realtimeChart.js new file mode 100644 index 0000000..7bff9a3 --- /dev/null +++ b/MoonlightServers.Frontend/wwwroot/realtimeChart.js @@ -0,0 +1,85 @@ +window.moonlightServersRealtimeChart = { + instances: new Map(), + init: function (id, elementId, maxDataPoints, minY, maxY, defaultLabels, defaultDataPoints) { + const canvas = document.getElementById(elementId); + + const labels = []; + labels.push(... defaultLabels); + + const dataPoints = []; + dataPoints.push(... defaultDataPoints); + + const chart = new Chart(canvas, { + type: 'line', + data: { + labels, + datasets: [{ + data: dataPoints, + borderColor: 'oklch(0.58 0.18 270)', + backgroundColor: 'rgba(55,138,221,0.15)', + borderWidth: 2, + pointRadius: 0, + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 400, + easing: 'easeInOutCubic' + }, + layout: {padding: 0}, + plugins: {legend: {display: false}, tooltip: {enabled: false}}, + scales: { + x: {display: false}, + y: {display: false, min: minY, max: maxY} + } + } + }); + + this.instances.set(id, { + chart: chart, + labels: labels, + dataPoints: dataPoints, + maxDataPoints: maxDataPoints + }); + }, + pushValue: function (id, label, val) { + const chartData = this.instances.get(id); + const isShifting = chartData.labels.length >= chartData.maxDataPoints; + + chartData.labels.push(label); + chartData.dataPoints.push(val); + + if (isShifting) { + // Animate the new point drawing in first... + chartData.chart.update({ + duration: 300, + easing: 'easeOutCubic', + lazy: false + }); + + // ...then silently trim the oldest point after the animation completes + setTimeout(() => { + chartData.labels.shift(); + chartData.dataPoints.shift(); + chartData.chart.update('none'); + }, 300); + } else { + chartData.chart.update({ + duration: 500, + easing: 'easeOutQuart', + lazy: false + }); + } + }, + destroy: function (id) { + const chartData = this.instances.get(id); + + chartData.chart.destroy(); + + this.instances.delete(id); + } +} \ No newline at end of file diff --git a/MoonlightServers.Shared/Admin/Nodes/NodeStatisticsDto.cs b/MoonlightServers.Shared/Admin/Nodes/NodeStatisticsDto.cs new file mode 100644 index 0000000..ae3bf25 --- /dev/null +++ b/MoonlightServers.Shared/Admin/Nodes/NodeStatisticsDto.cs @@ -0,0 +1,49 @@ +namespace MoonlightServers.Shared.Admin.Nodes; + +public record NodeStatisticsDto( + CpuSnapshotDto Cpu, + MemoryInfoDto Memory, + IReadOnlyList Disks, + IReadOnlyList Network, + TimeSpan Uptime +); + +public record CpuSnapshotDto( + string ModelName, + double TotalUsagePercent, + IReadOnlyList CoreUsagePercents +); + +public record MemoryInfoDto( + long TotalBytes, + long UsedBytes, + long FreeBytes, + long CachedBytes, + long BuffersBytes, + long AvailableBytes, + double UsedPercent +); + +public record DiskInfoDto( + string MountPoint, + string Device, + string FileSystem, + long TotalBytes, + long UsedBytes, + long FreeBytes, + double UsedPercent, + long InodesTotal, + long InodesUsed, + long InodesFree, + double InodesUsedPercent +); + +public record NetworkInterfaceInfoDto( + string Name, + long RxBytesPerSec, + long TxBytesPerSec, + long RxPacketsPerSec, + long TxPacketsPerSec, + long RxErrors, + long TxErrors +); \ No newline at end of file diff --git a/MoonlightServers.Shared/SerializationContext.cs b/MoonlightServers.Shared/SerializationContext.cs index 772cee4..5ae02b2 100644 --- a/MoonlightServers.Shared/SerializationContext.cs +++ b/MoonlightServers.Shared/SerializationContext.cs @@ -11,6 +11,8 @@ namespace MoonlightServers.Shared; // - Node [JsonSerializable(typeof(CreateNodeDto))] [JsonSerializable(typeof(UpdateNodeDto))] +[JsonSerializable(typeof(NodeHealthDto))] +[JsonSerializable(typeof(NodeStatisticsDto))] [JsonSerializable(typeof(NodeDto))] [JsonSerializable(typeof(PagedData))]