Compare commits
2 Commits
ba5e364c05
...
ecf0e01914
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf0e01914 | |||
| 6d447a0ff9 |
@@ -102,6 +102,9 @@
|
|||||||
<script src="/_content/ShadcnBlazor/interop.js" defer></script>
|
<script src="/_content/ShadcnBlazor/interop.js" defer></script>
|
||||||
<script src="/_content/ShadcnBlazor.Extras/interop.js" defer></script>
|
<script src="/_content/ShadcnBlazor.Extras/interop.js" defer></script>
|
||||||
<script src="/_content/ShadcnBlazor.Extras/codemirror-bundle.js" defer></script>
|
<script src="/_content/ShadcnBlazor.Extras/codemirror-bundle.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/_content/Moonlight.Frontend/chart.umd.js" defer></script>
|
||||||
|
|
||||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using MoonlightServers.Api.Infrastructure.Database.Entities;
|
using MoonlightServers.Api.Infrastructure.Database.Entities;
|
||||||
|
using MoonlightServers.DaemonShared.Http.Daemon;
|
||||||
using MoonlightServers.Shared.Admin.Nodes;
|
using MoonlightServers.Shared.Admin.Nodes;
|
||||||
using Riok.Mapperly.Abstractions;
|
using Riok.Mapperly.Abstractions;
|
||||||
|
|
||||||
@@ -14,4 +15,6 @@ public static partial class NodeMapper
|
|||||||
public static partial IQueryable<NodeDto> ProjectToDto(this IQueryable<Node> nodes);
|
public static partial IQueryable<NodeDto> ProjectToDto(this IQueryable<Node> nodes);
|
||||||
public static partial Node ToEntity(CreateNodeDto dto);
|
public static partial Node ToEntity(CreateNodeDto dto);
|
||||||
public static partial void Merge([MappingTarget] Node node, UpdateNodeDto dto);
|
public static partial void Merge([MappingTarget] Node node, UpdateNodeDto dto);
|
||||||
|
|
||||||
|
public static partial NodeStatisticsDto ToDto(SystemStatisticsDto dto);
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,19 @@ public class NodeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SystemStatisticsDto> 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<SystemStatisticsDto>(SerializationContext.Default.Options))!;
|
||||||
|
}
|
||||||
|
|
||||||
private static HttpRequestMessage CreateBaseRequest(
|
private static HttpRequestMessage CreateBaseRequest(
|
||||||
Node node,
|
Node node,
|
||||||
[StringSyntax(StringSyntaxAttribute.Uri)]
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
|||||||
40
MoonlightServers.Api/Admin/Nodes/StatisticsController.cs
Normal file
40
MoonlightServers.Api/Admin/Nodes/StatisticsController.cs
Normal file
@@ -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<Node> NodeRepository;
|
||||||
|
|
||||||
|
public StatisticsController(NodeService nodeService, DatabaseRepository<Node> nodeRepository)
|
||||||
|
{
|
||||||
|
NodeService = nodeService;
|
||||||
|
NodeRepository = nodeRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<NodeStatisticsDto>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
MoonlightServers.Daemon/Http/Controllers/SystemController.cs
Normal file
22
MoonlightServers.Daemon/Http/Controllers/SystemController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
MoonlightServers.Daemon/Mappers/SystemStatisticsMapper.cs
Normal file
14
MoonlightServers.Daemon/Mappers/SystemStatisticsMapper.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Docker.DotNet.Enhanced" Version="3.132.0" />
|
<PackageReference Include="Docker.DotNet.Enhanced" Version="3.132.0" />
|
||||||
|
<PackageReference Include="Riok.Mapperly" Version="5.0.0-next.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -26,6 +28,18 @@
|
|||||||
<Compile Update="ServerSystem\Server.Update.cs">
|
<Compile Update="ServerSystem\Server.Update.cs">
|
||||||
<DependentUpon>Server.cs</DependentUpon>
|
<DependentUpon>Server.cs</DependentUpon>
|
||||||
</Compile>
|
</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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace MoonlightServers.DaemonShared.Http.Daemon;
|
||||||
|
|
||||||
|
public record SystemStatisticsDto(
|
||||||
|
CpuSnapshotDto Cpu,
|
||||||
|
MemoryInfoDto Memory,
|
||||||
|
IReadOnlyList<DiskInfoDto> Disks,
|
||||||
|
IReadOnlyList<NetworkInterfaceInfoDto> Network,
|
||||||
|
TimeSpan Uptime
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CpuSnapshotDto(
|
||||||
|
string ModelName,
|
||||||
|
double TotalUsagePercent,
|
||||||
|
IReadOnlyList<double> 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
|
||||||
|
);
|
||||||
@@ -5,6 +5,7 @@ using MoonlightServers.DaemonShared.Http.Daemon;
|
|||||||
namespace MoonlightServers.DaemonShared.Http;
|
namespace MoonlightServers.DaemonShared.Http;
|
||||||
|
|
||||||
[JsonSerializable(typeof(HealthDto))]
|
[JsonSerializable(typeof(HealthDto))]
|
||||||
|
[JsonSerializable(typeof(SystemStatisticsDto))]
|
||||||
[JsonSerializable(typeof(ProblemDetails))]
|
[JsonSerializable(typeof(ProblemDetails))]
|
||||||
|
|
||||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
<LazyLoader Load="LoadAsync">
|
|
||||||
@if (Node == null)
|
|
||||||
{
|
|
||||||
<Empty>
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
|
||||||
<SearchIcon/>
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>Node not found</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
A node with this id cannot be found
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="formContext">
|
|
||||||
|
|
||||||
<div class="flex flex-row justify-between">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h1 class="text-xl font-semibold">Update Node</h1>
|
|
||||||
<div class="text-muted-foreground">
|
|
||||||
Update node @Node.Name
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row gap-x-1.5">
|
|
||||||
<Button Variant="ButtonVariant.Secondary">
|
|
||||||
<Slot>
|
|
||||||
<a href="/admin/servers?tab=nodes" @attributes="context">
|
|
||||||
<ChevronLeftIcon/>
|
|
||||||
Back
|
|
||||||
</a>
|
|
||||||
</Slot>
|
|
||||||
</Button>
|
|
||||||
<SubmitButton>
|
|
||||||
<CheckIcon/>
|
|
||||||
Continue
|
|
||||||
</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<DataAnnotationsValidator/>
|
|
||||||
|
|
||||||
<FieldGroup>
|
|
||||||
<FormValidationSummary/>
|
|
||||||
|
|
||||||
<FieldSet>
|
|
||||||
<FieldGroup>
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="nodeName">Name</FieldLabel>
|
|
||||||
<TextInputField
|
|
||||||
@bind-Value="Request.Name"
|
|
||||||
id="nodeName"/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="nodeHttpEndpoint">HTTP Endpoint</FieldLabel>
|
|
||||||
<TextInputField
|
|
||||||
@bind-Value="Request.HttpEndpointUrl"
|
|
||||||
id="nodeHttpEndpoint"
|
|
||||||
placeholder="http://example.com:8080"/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</EnhancedEditForm>
|
|
||||||
}
|
|
||||||
</LazyLoader>
|
|
||||||
|
|
||||||
@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<NodeDto>(SerializationContext.Default.Options);
|
|
||||||
|
|
||||||
if(Node == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Request = new UpdateNodeDto()
|
|
||||||
{
|
|
||||||
Name = Node.Name,
|
|
||||||
HttpEndpointUrl = Node.HttpEndpointUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@using Microsoft.Extensions.Logging
|
@using Microsoft.Extensions.Logging
|
||||||
|
@using MoonlightServers.Shared
|
||||||
@using MoonlightServers.Shared.Admin.Nodes
|
@using MoonlightServers.Shared.Admin.Nodes
|
||||||
@using ShadcnBlazor.Tooltips
|
@using ShadcnBlazor.Tooltips
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ else
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await HttpClient.GetFromJsonAsync<NodeHealthDto>($"api/admin/servers/nodes/{Node.Id}/health");
|
var result = await HttpClient.GetFromJsonAsync<NodeHealthDto>($"api/admin/servers/nodes/{Node.Id}/health", SerializationContext.Default.Options);
|
||||||
|
|
||||||
if(result == null)
|
if(result == null)
|
||||||
return;
|
return;
|
||||||
|
|||||||
85
MoonlightServers.Frontend/Admin/Nodes/SettingsTab.razor
Normal file
85
MoonlightServers.Frontend/Admin/Nodes/SettingsTab.razor
Normal file
@@ -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
|
||||||
|
|
||||||
|
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="formContext">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<DataAnnotationsValidator/>
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<FormValidationSummary/>
|
||||||
|
|
||||||
|
<FieldSet>
|
||||||
|
<FieldGroup>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="nodeName">Name</FieldLabel>
|
||||||
|
<TextInputField
|
||||||
|
@bind-Value="Request.Name"
|
||||||
|
id="nodeName"/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="nodeHttpEndpoint">HTTP Endpoint</FieldLabel>
|
||||||
|
<TextInputField
|
||||||
|
@bind-Value="Request.HttpEndpointUrl"
|
||||||
|
id="nodeHttpEndpoint"
|
||||||
|
placeholder="http://example.com:8080"/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter ClassName="justify-end">
|
||||||
|
<SubmitButton>Save changes</SubmitButton>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</EnhancedEditForm>
|
||||||
|
|
||||||
|
@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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
301
MoonlightServers.Frontend/Admin/Nodes/StatisticsTab.razor
Normal file
301
MoonlightServers.Frontend/Admin/Nodes/StatisticsTab.razor
Normal file
@@ -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
|
||||||
|
|
||||||
|
<h3 class="text-base font-semibold mt-5 mb-2">Overview</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||||
|
<div class="col-span-1">
|
||||||
|
<RealtimeChart @ref="CpuChart" T="double"
|
||||||
|
Title="CPU Usage"
|
||||||
|
DisplayField="@(d => $"{Math.Round(d, 2)}%")"
|
||||||
|
ValueField="d => d"
|
||||||
|
ClassName="h-32"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1">
|
||||||
|
<RealtimeChart @ref="MemoryChart" T="MemoryDataPoint"
|
||||||
|
Title="Memory Usage"
|
||||||
|
DisplayField="@(d => Formatter.FormatSize(d.UsedMemory))"
|
||||||
|
ValueField="d => d.Percent"
|
||||||
|
ClassName="h-32"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1">
|
||||||
|
<RealtimeChart @ref="NetworkInChart" T="long"
|
||||||
|
Title="Incoming Traffic"
|
||||||
|
DisplayField="@(d => $"{Formatter.FormatSize(d)}/s")"
|
||||||
|
Min="0"
|
||||||
|
Max="512"
|
||||||
|
ValueField="@(d => d / 1024f / 1024f)"
|
||||||
|
ClassName="h-32"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1">
|
||||||
|
<RealtimeChart @ref="NetworkOutChart" T="long"
|
||||||
|
Title="Outgoing Traffic"
|
||||||
|
DisplayField="@(d => $"{Formatter.FormatSize(d)}/s")"
|
||||||
|
Min="0"
|
||||||
|
Max="512"
|
||||||
|
ValueField="@(d => d / 1024f / 1024f)"
|
||||||
|
ClassName="h-32"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card ClassName="col-span-1">
|
||||||
|
@if (HasLoaded && StatisticsDto != null)
|
||||||
|
{
|
||||||
|
<CardHeader ClassName="gap-0">
|
||||||
|
<CardDescription>Uptime</CardDescription>
|
||||||
|
<CardTitle ClassName="text-lg">@Formatter.FormatDuration(StatisticsDto.Uptime)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<CardContent ClassName="flex justify-center items-center">
|
||||||
|
<Spinner ClassName="size-8"/>
|
||||||
|
</CardContent>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card ClassName="col-span-1">
|
||||||
|
@if (HasLoaded && HealthDto != null)
|
||||||
|
{
|
||||||
|
<CardHeader ClassName="gap-0">
|
||||||
|
<CardDescription>Health Status</CardDescription>
|
||||||
|
<CardTitle ClassName="text-lg">
|
||||||
|
@if (HealthDto.IsHealthy)
|
||||||
|
{
|
||||||
|
<span class="text-green-500">Healthy</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-destructive">Unhealthy</span>
|
||||||
|
}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<CardContent ClassName="flex justify-center items-center">
|
||||||
|
<Spinner ClassName="size-8"/>
|
||||||
|
</CardContent>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-semibold mt-8 mb-2">Details</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
|
||||||
|
<Card ClassName="col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>System Details</CardTitle>
|
||||||
|
<CardDescription>Details over your general system configuration</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
@if (StatisticsDto == null)
|
||||||
|
{
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||||
|
<CpuIcon/>
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No details</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
No details about your system found
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-muted-foreground font-medium">CPU Model</span>
|
||||||
|
<span>
|
||||||
|
@StatisticsDto.Cpu.ModelName
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-muted-foreground font-medium">Total Memory</span>
|
||||||
|
<span>
|
||||||
|
@Formatter.FormatSize(StatisticsDto.Memory.TotalBytes)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-muted-foreground font-medium">Total Disk Space</span>
|
||||||
|
<span>
|
||||||
|
@{
|
||||||
|
var totalDiskSpace = StatisticsDto
|
||||||
|
.Disks
|
||||||
|
.DistinctBy(x => x.Device)
|
||||||
|
.Sum(x => x.TotalBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Formatter.FormatSize(totalDiskSpace)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card ClassName="col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Disk Details</CardTitle>
|
||||||
|
<CardDescription>Details over all your mounted disks</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
@if (StatisticsDto == null)
|
||||||
|
{
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||||
|
<HardDriveIcon/>
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No details</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
No details about disk and their usage found
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="space-y-4">
|
||||||
|
@foreach (var disk in StatisticsDto.Disks)
|
||||||
|
{
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
@disk.MountPoint
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
@disk.FileSystem
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
@disk.Device
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
@Formatter.FormatSize(disk.UsedBytes) / @Formatter.FormatSize(disk.TotalBytes)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress Value="(int)disk.UsedPercent" Max="100"></Progress>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
[Parameter] public NodeDto Node { get; set; }
|
||||||
|
|
||||||
|
private NodeHealthDto? HealthDto;
|
||||||
|
private NodeStatisticsDto? StatisticsDto;
|
||||||
|
|
||||||
|
private bool HasLoaded;
|
||||||
|
|
||||||
|
private RealtimeChart<double>? CpuChart;
|
||||||
|
|
||||||
|
private RealtimeChart<long>? NetworkInChart;
|
||||||
|
|
||||||
|
private RealtimeChart<long>? NetworkOutChart;
|
||||||
|
|
||||||
|
private RealtimeChart<MemoryDataPoint>? MemoryChart;
|
||||||
|
private Timer? Timer;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
HealthDto = await HttpClient.GetFromJsonAsync<NodeHealthDto>(
|
||||||
|
$"api/admin/servers/nodes/{Node.Id}/health",
|
||||||
|
SerializationContext.Default.Options
|
||||||
|
);
|
||||||
|
|
||||||
|
if (HealthDto is { IsHealthy: true })
|
||||||
|
{
|
||||||
|
StatisticsDto = await HttpClient.GetFromJsonAsync<NodeStatisticsDto>(
|
||||||
|
$"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<NodeStatisticsDto>(
|
||||||
|
$"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);
|
||||||
|
}
|
||||||
90
MoonlightServers.Frontend/Admin/Nodes/View.razor
Normal file
90
MoonlightServers.Frontend/Admin/Nodes/View.razor
Normal file
@@ -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)]
|
||||||
|
|
||||||
|
<LazyLoader Load="LoadAsync">
|
||||||
|
@if (Dto == null)
|
||||||
|
{
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||||
|
<SearchIcon/>
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Node not found</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
A node with this id cannot be found
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-row justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h1 class="text-xl font-semibold">@Dto.Name</h1>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
View details for @Dto.Name
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-x-1.5">
|
||||||
|
<Button Variant="ButtonVariant.Secondary">
|
||||||
|
<Slot>
|
||||||
|
<a href="/admin/servers?tab=nodes" @attributes="context">
|
||||||
|
<ChevronLeftIcon/>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</Slot>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<Tabs DefaultValue="statistics">
|
||||||
|
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
|
||||||
|
<TabsTrigger Value="statistics">
|
||||||
|
<ChartColumnBigIcon/>
|
||||||
|
Statistics
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger Value="settings">
|
||||||
|
<SettingsIcon/>
|
||||||
|
Settings
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent Value="statistics">
|
||||||
|
<StatisticsTab Node="Dto" />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent Value="settings">
|
||||||
|
<SettingsTab Node="Dto" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</LazyLoader>
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
[Parameter] public int Id { get; set; }
|
||||||
|
|
||||||
|
private NodeDto? Dto;
|
||||||
|
|
||||||
|
private async Task LoadAsync(LazyLoader _)
|
||||||
|
{
|
||||||
|
Dto = await HttpClient.GetFromJsonAsync<NodeDto>(
|
||||||
|
$"api/admin/servers/nodes/{Id}",
|
||||||
|
SerializationContext.Default.Options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@inherits Moonlight.Frontend.Infrastructure.Hooks.LayoutMiddlewareBase
|
||||||
|
|
||||||
|
@ChildContent
|
||||||
|
|
||||||
|
<script src="/_content/MoonlightServers.Frontend/realtimeChart.js"></script>
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Client\" />
|
<Folder Include="Client\" />
|
||||||
<Folder Include="wwwroot\"/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
93
MoonlightServers.Frontend/Shared/RealtimeChart.razor
Normal file
93
MoonlightServers.Frontend/Shared/RealtimeChart.razor
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@using ShadcnBlazor.Cards
|
||||||
|
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@typeparam T
|
||||||
|
|
||||||
|
<Card ClassName="py-0 overflow-hidden">
|
||||||
|
<CardContent ClassName="@($"px-0 relative overflow-hidden {ClassName}")">
|
||||||
|
<div class="absolute top-6 left-6 z-10">
|
||||||
|
@if (!string.IsNullOrEmpty(Title))
|
||||||
|
{
|
||||||
|
<CardDescription>@Title</CardDescription>
|
||||||
|
}
|
||||||
|
<CardTitle ClassName="text-lg">
|
||||||
|
@if (CurrentValue != null)
|
||||||
|
{
|
||||||
|
@DisplayField.Invoke(CurrentValue)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>-</span>
|
||||||
|
}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<canvas id="@Identifier" class="absolute block rounded-xl -left-5 -right-5 top-0 -bottom-2 w-[calc(100%+30px)]! h-[calc(100%+8px)]!">
|
||||||
|
</canvas>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
[Parameter] public IEnumerable<T>? DefaultItems { get; set; }
|
||||||
|
[Parameter] public Func<T, string> DisplayField { get; set; }
|
||||||
|
[Parameter] public Func<T, double> 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);
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ public sealed class Startup : MoonlightPlugin
|
|||||||
{
|
{
|
||||||
options.Assemblies.Add(typeof(Startup).Assembly);
|
options.Assemblies.Add(typeof(Startup).Assembly);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.Configure<LayoutMiddlewareOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Add<ScriptImports>();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void PostBuild(WebAssemblyHost application)
|
public override void PostBuild(WebAssemblyHost application)
|
||||||
|
|||||||
85
MoonlightServers.Frontend/wwwroot/realtimeChart.js
Normal file
85
MoonlightServers.Frontend/wwwroot/realtimeChart.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
MoonlightServers.Shared/Admin/Nodes/NodeStatisticsDto.cs
Normal file
49
MoonlightServers.Shared/Admin/Nodes/NodeStatisticsDto.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
namespace MoonlightServers.Shared.Admin.Nodes;
|
||||||
|
|
||||||
|
public record NodeStatisticsDto(
|
||||||
|
CpuSnapshotDto Cpu,
|
||||||
|
MemoryInfoDto Memory,
|
||||||
|
IReadOnlyList<DiskInfoDto> Disks,
|
||||||
|
IReadOnlyList<NetworkInterfaceInfoDto> Network,
|
||||||
|
TimeSpan Uptime
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CpuSnapshotDto(
|
||||||
|
string ModelName,
|
||||||
|
double TotalUsagePercent,
|
||||||
|
IReadOnlyList<double> 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
|
||||||
|
);
|
||||||
@@ -11,6 +11,8 @@ namespace MoonlightServers.Shared;
|
|||||||
// - Node
|
// - Node
|
||||||
[JsonSerializable(typeof(CreateNodeDto))]
|
[JsonSerializable(typeof(CreateNodeDto))]
|
||||||
[JsonSerializable(typeof(UpdateNodeDto))]
|
[JsonSerializable(typeof(UpdateNodeDto))]
|
||||||
|
[JsonSerializable(typeof(NodeHealthDto))]
|
||||||
|
[JsonSerializable(typeof(NodeStatisticsDto))]
|
||||||
[JsonSerializable(typeof(NodeDto))]
|
[JsonSerializable(typeof(NodeDto))]
|
||||||
[JsonSerializable(typeof(PagedData<NodeDto>))]
|
[JsonSerializable(typeof(PagedData<NodeDto>))]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user