Implemented node system statistics

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

View File

@@ -102,6 +102,9 @@
<script src="/_content/ShadcnBlazor/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/Moonlight.Frontend/chart.umd.js" defer></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>

View File

@@ -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<NodeDto> ProjectToDto(this IQueryable<Node> 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);
}

View File

@@ -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(
Node node,
[StringSyntax(StringSyntaxAttribute.Uri)]

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

View File

@@ -0,0 +1,36 @@
using System.Runtime.InteropServices;
namespace MoonlightServers.Daemon.Helpers;
internal static partial class NativeMethods
{
[StructLayout(LayoutKind.Sequential)]
internal struct StatVfsResult
{
public ulong bsize;
public ulong frsize;
public ulong blocks;
public ulong bfree;
public ulong bavail;
public ulong files;
public ulong ffree;
public ulong favail;
public ulong fsid;
public ulong flag;
public ulong namemax;
private ulong __spare0; // } kernel reserved padding —
private ulong __spare1; // } never read, exist only to
private ulong __spare2; // } match the 112-byte struct
private ulong __spare3; // } statvfs layout on x86-64
private ulong __spare4; // } Linux so the fields above
private ulong __spare5; // } land at the right offsets
}
// SetLastError = true tells the marshaller to capture errno immediately
// after the call, before any other code can clobber it. Retrieve it with
// Marshal.GetLastPInvokeError() which maps to the thread-local errno value.
[LibraryImport("libc", EntryPoint = "statvfs",
StringMarshalling = StringMarshalling.Utf8,
SetLastError = true)]
internal static partial int StatVfs(string path, out StatVfsResult buf);
}

View File

@@ -0,0 +1,76 @@
namespace MoonlightServers.Daemon.Helpers;
public partial class SystemMetrics
{
public record CpuSnapshot(
string ModelName,
double TotalUsagePercent,
IReadOnlyList<double> CoreUsagePercents
);
private record RawCpuLine(
long User,
long Nice,
long System,
long Idle,
long Iowait,
long Irq,
long Softirq,
long Steal
);
private static async Task<List<RawCpuLine>> ReadRawCpuStatsAsync()
{
var lines = await File.ReadAllLinesAsync("/proc/stat");
var result = new List<RawCpuLine>();
foreach (var line in lines)
{
// All cpu* lines appear at the top of the file; stop on the first non-cpu line.
if (!line.StartsWith("cpu", StringComparison.Ordinal))
break;
var p = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (p.Length < 8)
continue;
result.Add(new RawCpuLine(
User: long.Parse(p[1]),
Nice: long.Parse(p[2]),
System: long.Parse(p[3]),
Idle: long.Parse(p[4]),
Iowait: long.Parse(p[5]),
Irq: long.Parse(p[6]),
Softirq: long.Parse(p[7]),
Steal: p.Length > 8 ? long.Parse(p[8]) : 0L
));
}
return result;
}
private static async Task<string> ReadCpuModelNameAsync()
{
var lines = await File.ReadAllLinesAsync("/proc/cpuinfo");
var line = lines.FirstOrDefault(l => l.StartsWith("model name", StringComparison.OrdinalIgnoreCase));
return line is not null ? line.Split(':')[1].Trim() : "Unknown";
}
private static CpuSnapshot ComputeCpuUsage(string modelName, List<RawCpuLine> s1, List<RawCpuLine> s2)
{
// Index 0 = aggregate "cpu" row; indices 1+ = "cpu0", "cpu1"
var totalUsage = s1.Count > 0 ? Usage(s1[0], s2[0]) : 0.0;
var coreUsages = s1.Skip(1).Zip(s2.Skip(1), Usage).ToList();
return new CpuSnapshot(modelName, totalUsage, coreUsages);
static double Usage(RawCpuLine a, RawCpuLine b)
{
var idleDelta = (b.Idle + b.Iowait) - (a.Idle + a.Iowait);
var totalDelta = (b.User + b.Nice + b.System + b.Idle + b.Iowait + b.Irq + b.Softirq + b.Steal)
- (a.User + a.Nice + a.System + a.Idle + a.Iowait + a.Irq + a.Softirq + a.Steal);
return totalDelta <= 0 ? 0.0 : Math.Round((1.0 - (double)idleDelta / totalDelta) * 100.0, 2);
}
}
}

View File

@@ -0,0 +1,149 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace MoonlightServers.Daemon.Helpers;
public partial class SystemMetrics
{
public record DiskInfo(
string MountPoint,
string Device,
string FileSystem,
long TotalBytes,
long UsedBytes,
long FreeBytes,
double UsedPercent,
long InodesTotal,
long InodesUsed,
long InodesFree,
double InodesUsedPercent
);
private static readonly HashSet<string> IgnoredFileSystems = new(StringComparer.OrdinalIgnoreCase)
{
"overlay", "aufs", // Container image layers
"tmpfs", "devtmpfs", "ramfs", // RAM-backed virtual filesystems
"sysfs", "proc", "devpts", // Kernel virtual filesystems
"cgroup", "cgroup2",
"pstore", "securityfs", "debugfs", "tracefs",
"mqueue", "hugetlbfs", "fusectl", "configfs",
"binfmt_misc", "nsfs", "rpc_pipefs",
"squashfs", // Snap package loop mounts
};
private static readonly string[] IgnoredMountPrefixes =
{
"/sys",
"/proc",
"/dev",
"/run/docker",
"/var/lib/docker",
"/var/lib/containers",
"/boot", "/boot/efi"
};
private static async Task<IReadOnlyList<DiskInfo>> ReadDisksAsync()
{
var lines = await File.ReadAllLinesAsync("/proc/mounts");
var results = new List<DiskInfo>();
foreach (var line in lines)
{
var parts = line.Split(' ');
if (parts.Length < 4) continue;
var device = parts[0];
var mountPoint = UnescapeOctal(parts[1]);
var fsType = parts[2];
if (IgnoredFileSystems.Contains(fsType)) continue;
if (IgnoredMountPrefixes.Any(p => mountPoint.StartsWith(p, StringComparison.Ordinal))) continue;
if (device == "none" || device.StartsWith("//", StringComparison.Ordinal)) continue;
var disk = ReadSingleDisk(device, mountPoint, fsType);
if (disk is not null)
results.Add(disk);
}
return results
.GroupBy(d => d.Device)
.Select(g => g.OrderBy(d => d.MountPoint.Length).First())
.OrderBy(d => d.MountPoint)
.ToList();
}
private static DiskInfo? ReadSingleDisk(string device, string mountPoint, string fsType)
{
if (NativeMethods.StatVfs(mountPoint, out var st) != 0)
{
var errno = Marshal.GetLastPInvokeError();
Console.WriteLine($"statvfs({mountPoint}) failed: errno {errno} ({new Win32Exception(errno).Message})");
return null;
}
var blockSize = (long)st.frsize;
var totalBytes = (long)st.blocks * blockSize;
var freeBytes = (long)st.bavail * blockSize;
if (totalBytes <= 0) return null;
var usedBytes = totalBytes - ((long)st.bfree * blockSize);
var usedPct = Math.Round((double)usedBytes / totalBytes * 100.0, 2);
long inodesTotal, inodesUsed, inodesFree;
double inodePct;
if (st.files == 0)
{
// Filesystem doesn't expose inode counts (FAT, exFAT, NTFS via ntfs-3g, etc.)
inodesTotal = inodesUsed = inodesFree = -1;
inodePct = -1.0;
}
else
{
inodesTotal = (long)st.files;
inodesFree = (long)st.ffree;
inodesUsed = inodesTotal - inodesFree;
inodePct = Math.Round((double)inodesUsed / inodesTotal * 100.0, 2);
}
return new DiskInfo(
MountPoint: mountPoint,
Device: device,
FileSystem: fsType,
TotalBytes: totalBytes,
UsedBytes: usedBytes,
FreeBytes: freeBytes,
UsedPercent: usedPct,
InodesTotal: inodesTotal,
InodesUsed: inodesUsed,
InodesFree: inodesFree,
InodesUsedPercent: inodePct
);
}
private static string UnescapeOctal(string s)
{
if (!s.Contains('\\')) return s;
var sb = new System.Text.StringBuilder(s.Length);
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '\\' && i + 3 < s.Length
&& s[i + 1] is >= '0' and <= '7'
&& s[i + 2] is >= '0' and <= '7'
&& s[i + 3] is >= '0' and <= '7')
{
sb.Append((char)((s[i + 1] - '0') * 64 + (s[i + 2] - '0') * 8 + (s[i + 3] - '0')));
i += 3;
}
else
{
sb.Append(s[i]);
}
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,59 @@
namespace MoonlightServers.Daemon.Helpers;
public partial class SystemMetrics
{
/// <summary>Memory figures derived from <c>/proc/meminfo</c>.</summary>
/// <param name="TotalBytes">Physical RAM installed.</param>
/// <param name="UsedBytes">RAM actively in use (<c>Total - Available</c>).</param>
/// <param name="FreeBytes">Completely unallocated RAM.</param>
/// <param name="CachedBytes">Page cache + reclaimable slab — matches the <c>cached</c> column in <c>free -h</c>.</param>
/// <param name="BuffersBytes">Kernel I/O buffer memory.</param>
/// <param name="AvailableBytes">Estimated RAM available for new allocations without swapping.</param>
/// <param name="UsedPercent">UsedBytes / TotalBytes as a percentage (0100).</param>
public record MemoryInfo(
long TotalBytes,
long UsedBytes,
long FreeBytes,
long CachedBytes,
long BuffersBytes,
long AvailableBytes,
double UsedPercent
);
// Memory — /proc/meminfo
/// <summary>
/// Parses <c>/proc/meminfo</c> into a <see cref="MemoryInfo"/> record.
/// The <c>CachedBytes</c> field is computed as
/// <c>Cached + SReclaimable - Shmem</c> to match the value shown by <c>free -h</c>.
/// </summary>
private static async Task<MemoryInfo> ReadMemoryAsync()
{
var lines = await File.ReadAllLinesAsync("/proc/meminfo");
var map = new Dictionary<string, long>(StringComparer.Ordinal);
foreach (var line in lines)
{
var colon = line.IndexOf(':');
if (colon < 0)
continue;
var key = line[..colon].Trim();
var val = line[(colon + 1)..].Trim().Split(' ')[0]; // Strip "kB" suffix.
if (long.TryParse(val, out var kb))
map[key] = kb * 1024L;
}
var total = map.GetValueOrDefault("MemTotal");
var available = map.GetValueOrDefault("MemAvailable");
var free = map.GetValueOrDefault("MemFree");
var buffers = map.GetValueOrDefault("Buffers");
var cached = map.GetValueOrDefault("Cached")
+ map.GetValueOrDefault("SReclaimable")
- map.GetValueOrDefault("Shmem");
var used = total - available;
var usedPct = total > 0 ? Math.Round((double)used / total * 100.0, 2) : 0.0;
return new MemoryInfo(total, used, free, cached, buffers, available, usedPct);
}
}

View File

@@ -0,0 +1,101 @@
namespace MoonlightServers.Daemon.Helpers;
public partial class SystemMetrics
{
/// <summary>Network throughput for a single interface, computed between two samples.</summary>
/// <param name="Name">Interface name, e.g. <c>eth0</c>, <c>ens3</c>.</param>
/// <param name="RxBytesPerSec">Received bytes per second.</param>
/// <param name="TxBytesPerSec">Transmitted bytes per second.</param>
/// <param name="RxPacketsPerSec">Received packets per second.</param>
/// <param name="TxPacketsPerSec">Transmitted packets per second.</param>
/// <param name="RxErrors">Cumulative receive error count (not a rate).</param>
/// <param name="TxErrors">Cumulative transmit error count (not a rate).</param>
public record NetworkInterfaceInfo(
string Name,
long RxBytesPerSec,
long TxBytesPerSec,
long RxPacketsPerSec,
long TxPacketsPerSec,
long RxErrors,
long TxErrors
);
// Network
private record RawNetLine(
string Iface,
long RxBytes,
long RxPackets,
long RxErrors,
long TxBytes,
long TxPackets,
long TxErrors
);
private static async Task<List<RawNetLine>> ReadRawNetStatsAsync()
{
var lines = await File.ReadAllLinesAsync("/proc/net/dev");
var result = new List<RawNetLine>();
// The first two lines are the column-header banner.
foreach (var line in lines.Skip(2))
{
var colon = line.IndexOf(':');
if (colon < 0)
continue;
var iface = line[..colon].Trim();
// Skip loopback and ephemeral veth pairs created by container runtimes.
if (iface == "lo" || iface.StartsWith("veth", StringComparison.Ordinal))
continue;
var f = line[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (f.Length < 16)
continue;
// Column layout (after the colon, 0-based):
// RX: [0]bytes [1]packets [2]errs [3]drop [4]fifo [5]frame [6]compressed [7]multicast
// TX: [8]bytes [9]packets [10]errs [11]drop [12]fifo [13]colls [14]carrier [15]compressed
result.Add(new RawNetLine(
Iface: iface,
RxBytes: long.Parse(f[0]),
RxPackets: long.Parse(f[1]),
RxErrors: long.Parse(f[2]),
TxBytes: long.Parse(f[8]),
TxPackets: long.Parse(f[9]),
TxErrors: long.Parse(f[10])
));
}
return result;
}
private static IReadOnlyList<NetworkInterfaceInfo> ComputeNetworkRates(
List<RawNetLine> s1,
List<RawNetLine> s2,
double intervalSecs
)
{
var prev = s1.ToDictionary(x => x.Iface);
var result = new List<NetworkInterfaceInfo>();
var div = intervalSecs > 0 ? intervalSecs : 1.0;
foreach (var cur in s2)
{
if (!prev.TryGetValue(cur.Iface, out var p))
continue;
result.Add(new NetworkInterfaceInfo(
Name: cur.Iface,
RxBytesPerSec: (long)((cur.RxBytes - p.RxBytes) / div),
TxBytesPerSec: (long)((cur.TxBytes - p.TxBytes) / div),
RxPacketsPerSec: (long)((cur.RxPackets - p.RxPackets) / div),
TxPacketsPerSec: (long)((cur.TxPackets - p.TxPackets) / div),
RxErrors: cur.RxErrors,
TxErrors: cur.TxErrors
));
}
return result;
}
}

View File

@@ -0,0 +1,59 @@
namespace MoonlightServers.Daemon.Helpers;
/// <summary>
/// Reads Linux system metrics directly from the <c>/proc</c> and <c>/sys</c>
/// pseudo-filesystems
/// </summary>
public static partial class SystemMetrics
{
public record SystemSnapshot(
CpuSnapshot Cpu,
MemoryInfo Memory,
IReadOnlyList<DiskInfo> Disks,
IReadOnlyList<NetworkInterfaceInfo> Network,
TimeSpan Uptime
);
/// <summary>
/// Collects a full system snapshot. The method waits <paramref name="sampleIntervalMs"/>
/// milliseconds between the two samples required to compute CPU and network rates.
/// All other reads happen in parallel during the second sampling window.
/// </summary>
/// <param name="sampleIntervalMs">
/// Interval between rate-measurement samples in milliseconds.
/// Larger values yield smoother CPU and network averages. Defaults to <c>500</c>.
/// </param>
/// <returns>A fully populated <see cref="SystemSnapshot"/>.</returns>
public static async Task<SystemSnapshot> ReadAllAsync(int sampleIntervalMs = 500)
{
// First samples — must complete before the delay.
var cpuSample1Task = ReadRawCpuStatsAsync();
var netSample1Task = ReadRawNetStatsAsync();
await Task.WhenAll(cpuSample1Task, netSample1Task);
await Task.Delay(sampleIntervalMs);
// Second samples + all independent reads run concurrently.
var cpuSample2Task = ReadRawCpuStatsAsync();
var netSample2Task = ReadRawNetStatsAsync();
var memTask = ReadMemoryAsync();
var diskTask = ReadDisksAsync();
var uptimeTask = ReadUptimeAsync();
var cpuNameTask = ReadCpuModelNameAsync();
await Task.WhenAll(cpuSample2Task, netSample2Task, memTask, diskTask, uptimeTask, cpuNameTask);
var cpu = ComputeCpuUsage(cpuNameTask.Result, cpuSample1Task.Result, cpuSample2Task.Result);
var network = ComputeNetworkRates(netSample1Task.Result, netSample2Task.Result, sampleIntervalMs / 1000.0);
return new SystemSnapshot(cpu, memTask.Result, diskTask.Result, network, uptimeTask.Result);
}
// Uptime
private static async Task<TimeSpan> ReadUptimeAsync()
{
var text = await File.ReadAllTextAsync("/proc/uptime");
var seconds = double.Parse(text.Split(' ')[0], System.Globalization.CultureInfo.InvariantCulture);
return TimeSpan.FromSeconds(seconds);
}
}

View File

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

View File

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

View File

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

View File

@@ -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
);

View File

@@ -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)]

View File

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

View File

@@ -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<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)
return;

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

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

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

View File

@@ -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";
}
}

View File

@@ -0,0 +1,5 @@
@inherits Moonlight.Frontend.Infrastructure.Hooks.LayoutMiddlewareBase
@ChildContent
<script src="/_content/MoonlightServers.Frontend/realtimeChart.js"></script>

View File

@@ -29,7 +29,6 @@
<ItemGroup>
<Folder Include="Client\" />
<Folder Include="wwwroot\"/>
</ItemGroup>
<ItemGroup>

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

View File

@@ -20,6 +20,11 @@ public sealed class Startup : MoonlightPlugin
{
options.Assemblies.Add(typeof(Startup).Assembly);
});
builder.Services.Configure<LayoutMiddlewareOptions>(options =>
{
options.Add<ScriptImports>();
});
}
public override void PostBuild(WebAssemblyHost application)

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

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

View File

@@ -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<NodeDto>))]