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

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