Implemented node system statistics
This commit is contained in:
@@ -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 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;
|
||||
|
||||
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>
|
||||
<Folder Include="Client\" />
|
||||
<Folder Include="wwwroot\"/>
|
||||
</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);
|
||||
});
|
||||
|
||||
builder.Services.Configure<LayoutMiddlewareOptions>(options =>
|
||||
{
|
||||
options.Add<ScriptImports>();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user