Improved node statistics. Added overview for single nodes and replaced mockup values with api fetched values for nodes list

This commit is contained in:
2025-05-27 00:17:42 +02:00
parent de682ab7ae
commit f2771acb49
19 changed files with 853 additions and 223 deletions

View File

@@ -1,5 +1,6 @@
using MoonCore.Attributes;
using MoonCore.Helpers;
using MoonlightServers.Shared.Http.Responses.Admin.Nodes.Statistics;
using MoonlightServers.Shared.Http.Responses.Admin.Nodes.Sys;
namespace MoonlightServers.Frontend.Services;
@@ -18,4 +19,18 @@ public class NodeService
{
return await HttpApiClient.GetJson<NodeSystemStatusResponse>($"api/admin/servers/nodes/{nodeId}/system/status");
}
public async Task<StatisticsResponse> GetStatistics(int nodeId)
{
return await HttpApiClient.GetJson<StatisticsResponse>(
$"api/admin/servers/nodes/{nodeId}/statistics"
);
}
public async Task<DockerStatisticsResponse> GetDockerStatistics(int nodeId)
{
return await HttpApiClient.GetJson<DockerStatisticsResponse>(
$"api/admin/servers/nodes/{nodeId}/statistics/docker"
);
}
}

View File

@@ -0,0 +1,218 @@
@using MoonCore.Blazor.Tailwind.Components
@using MoonCore.Blazor.Tailwind.Toasts
@using MoonCore.Helpers
@using MoonlightServers.Frontend.Services
@using MoonlightServers.Shared.Http.Responses.Admin.Nodes
@using MoonlightServers.Shared.Http.Responses.Admin.Nodes.Statistics
@inject NodeService NodeService
@inject ToastService ToastService
@inject ILogger<OverviewNodeUpdate> Logger
@implements IDisposable
<LazyLoader Load="Load">
<div class="mb-3 mt-5 text-xl font-semibold">
Overview
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div class="col-span-1 card card-body">
<div class="flex justify-between">
<p class="text-xl font-semibold text-slate-200">
@Math.Round(Statistics.Cpu.Usage, 2)%
</p>
<i class="icon-cpu text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<span class="truncate">
CPU: @Statistics.Cpu.Model
</span>
</div>
</div>
<div class="col-span-1 card card-body">
<div class="flex justify-between">
<p class="text-xl font-semibold text-slate-200">
@Formatter.FormatSize(Statistics.Memory.Total - Statistics.Memory.Available)
/
@Formatter.FormatSize(Statistics.Memory.Total)
</p>
<i class="icon-memory-stick text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
Memory
</div>
</div>
<div class="col-span-1 card card-body">
<div class="flex justify-between">
<div class="text-xl font-semibold text-slate-200">
@Formatter.FormatSize(Statistics.Memory.SwapTotal - Statistics.Memory.SwapFree)
/
@Formatter.FormatSize(Statistics.Memory.SwapTotal)
</div>
<i class="icon-shapes text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
Swap
</div>
</div>
</div>
<div class="mb-3 mt-5 text-xl font-semibold">
CPU
</div>
<div class="card card-body grid grid-cols-1 lg:grid-cols-2 gap-y-2 gap-x-5">
@{
var i = 0;
}
@foreach (var usage in Statistics.Cpu.UsagePerCore)
{
var percentRounded = Math.Round(usage, 2);
<div class="flex flex-row items-center col-span-1">
<div class="text-sm text-slate-300 me-1.5 grow-0 flex flex-col">
<span>#@(i)</span>
</div>
<div class="grow">
<div class="progress bg-gray-750">
<div style="width: @(usage)%"
class="progress-bar h-3 @(GetBackgroundColorByPercent(percentRounded))"></div>
</div>
</div>
</div>
i++;
}
</div>
<div class="mb-3 mt-5 text-xl font-semibold">
Disks
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
@foreach (var disk in Statistics.Disks)
{
var usedPercent = Math.Round((disk.DiskTotal - disk.DiskFree) / (double)disk.DiskTotal * 100, 2);
var iNodesPercent = Math.Round((disk.InodesTotal - disk.InodesFree) / (double)disk.InodesTotal * 100, 2);
<div class="col-span-1 card card-body">
<div class="flex items-center">
<div class="grow">
<div class="progress bg-gray-750">
<div style="width: @(usedPercent)%"
class="progress-bar h-3 @(GetBackgroundColorByPercent(usedPercent))"></div>
</div>
</div>
</div>
<div class="text-sm text-slate-300 mt-2.5 flex flex-col">
<div>
Device: <span class="font-semibold">@disk.Device</span> - Mounted at: <span class="font-semibold truncate">@disk.MountPath</span>
</div>
<div>
Used: <span class="font-semibold">@Formatter.FormatSize(disk.DiskTotal - disk.DiskFree)</span>
Total: <span class="font-semibold">@Formatter.FormatSize(disk.DiskTotal)</span>
</div>
<div>
INodes: <span class="font-semibold">@(iNodesPercent)%</span>
</div>
</div>
</div>
}
</div>
<div class="mb-3 mt-5 text-xl font-semibold">
Docker
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div class="col-span-1 card card-body">
<div class="flex justify-between">
<p class="text-xl font-semibold text-slate-200">
@Formatter.FormatSize(DockerStatistics.ImagesUsed) (@Formatter.FormatSize(DockerStatistics.ImagesReclaimable) unused)
</p>
<i class="icon-gallery-horizontal-end text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
Images
</div>
</div>
<div class="col-span-1 card card-body">
<div class="flex justify-between">
<p class="text-xl font-semibold text-slate-200">
@Formatter.FormatSize(DockerStatistics.ContainersUsed) ( @Formatter.FormatSize(DockerStatistics.ContainersReclaimable) unused)
</p>
<i class="icon-container text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
Containers
</div>
</div>
<div class="col-span-1 card card-body">
<div class="flex justify-between">
<p class="text-xl font-semibold text-slate-200">
@Formatter.FormatSize(DockerStatistics.BuildCacheUsed) (@Formatter.FormatSize(DockerStatistics.BuildCacheReclaimable) unused)
</p>
<i class="icon-hard-hat text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
Build Cache
</div>
</div>
</div>
</LazyLoader>
@code
{
[Parameter] public NodeDetailResponse Node { get; set; }
private StatisticsResponse Statistics;
private DockerStatisticsResponse DockerStatistics;
private Timer? UpdateTimer;
private async Task Load(LazyLoader _)
{
Statistics = await NodeService.GetStatistics(Node.Id);
DockerStatistics = await NodeService.GetDockerStatistics(Node.Id);
UpdateTimer = new Timer(HandleUpdate, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3));
}
private async void HandleUpdate(object? _)
{
try
{
Statistics = await NodeService.GetStatistics(Node.Id);
DockerStatistics = await NodeService.GetDockerStatistics(Node.Id);
await InvokeAsync(StateHasChanged);
}
catch (Exception e)
{
Logger.LogWarning("An error occured while fetching status update: {e}", e);
await ToastService.Danger("Unable to fetch status update", e.Message);
}
}
private string GetBackgroundColorByPercent(double percent)
{
if (percent < 70)
return "bg-success";
else if (percent < 80)
return "bg-warning";
else
return "bg-danger";
}
public void Dispose()
{
UpdateTimer?.Dispose();
}
}

View File

@@ -8,12 +8,14 @@
@using MoonCore.Blazor.Tailwind.Dt
@using MoonCore.Blazor.Tailwind.Toasts
@using MoonlightServers.Frontend.Services
@using MoonlightServers.Shared.Http.Responses.Admin.Nodes.Statistics
@using MoonlightServers.Shared.Http.Responses.Admin.Nodes.Sys
@inject HttpApiClient ApiClient
@inject NodeService NodeService
@inject AlertService AlertService
@inject ToastService ToastService
@inject ILogger<Index> Logger
@attribute [Authorize(Policy = "permissions:admin.servers.nodes.get")]
@@ -31,8 +33,8 @@
<DataTable TItem="NodeDetailResponse">
<Configuration>
<Pagination TItem="NodeDetailResponse" ItemSource="LoadData" />
<Pagination TItem="NodeDetailResponse" ItemSource="LoadData"/>
<DataTableColumn TItem="NodeDetailResponse" Field="@(x => x.Id)" Name="Id"/>
<DataTableColumn TItem="NodeDetailResponse" Field="@(x => x.Name)" Name="Name">
<ColumnTemplate>
@@ -44,86 +46,96 @@
<DataTableColumn TItem="NodeDetailResponse" Field="@(x => x.Fqdn)" Name="Fqdn"/>
<DataTableColumn TItem="NodeDetailResponse" Field="@(x => x.Fqdn)" Name="Status">
<ColumnTemplate>
<LazyLoader Load="_ => LoadNodeStatus(context.Id)">
@{
bool isFetched;
NodeSystemStatusResponse? data;
@{
var isFetched = StatusResponses.TryGetValue(context.Id, out var data);
}
lock (Responses)
isFetched = Responses.TryGetValue(context.Id, out data);
}
@if (isFetched)
@if (isFetched)
{
if (data == null)
{
if (data == null)
{
<span class="text-danger">
<i class="icon-server-offg text-base me-1 align-middle"></i>
<span class="align-middle">
API Error
</span>
<div class="text-danger flex items-center">
<i class="icon-server-off text-base me-1"></i>
<span>
API Error
</span>
}
else
{
if (data.RoundtripSuccess)
{
<span class="text-success">
<i class="icon-check text-base me-1 align-middle"></i>
<span class="align-middle">Online (@(data.Version))</span>
</span>
}
else
{
<span class="text-danger">
<i class="icon-server-off text-base me-1 align-middle"></i>
<span class="align-middle">
Error
<a @onclick="() => ShowErrorDetails(context.Id)" @onclick:preventDefault
href="#" class="ms-1 text-gray-600">Details</a>
</span>
</span>
}
}
</div>
}
else
{
<span class="text-gray-500">
<i class="icon-loader text-base me-1 align-middle"></i>
<span class="align-middle">Loading</span>
</span>
if (data.RoundtripSuccess)
{
<div class="text-success flex items-center">
<i class="icon-check text-base me-1"></i>
<span>Online (@(data.Version))</span>
</div>
}
else
{
<div class="text-danger flex items-center">
<i class="icon-server-off text-base me-1"></i>
<span class="me-2">
Error
</span>
<a @onclick="() => ShowErrorDetails(context.Id)" @onclick:preventDefault
href="#" class="ms-1 text-gray-600">Details</a>
</div>
}
}
</LazyLoader>
}
else
{
<div class="text-gray-500">
<i class="icon-loader text-base me-1 align-middle"></i>
<span class="align-middle">Loading</span>
</div>
}
</ColumnTemplate>
</DataTableColumn>
<DataTableColumn TItem="NodeDetailResponse"
Name="Utilization"
HeaderCss="p-2 font-semibold text-left hidden xl:table-cell"
ColumnCss="p-2 text-left font-normal hidden xl:table-cell">
<ColumnTemplate>
<div>
<i class="icon-cpu text-lg me-1 align-middle text-primary"></i>
<span class="align-middle">33% of 6 Cores</span>
</div>
</ColumnTemplate>
</DataTableColumn>
<DataTableColumn TItem="NodeDetailResponse"
HeaderCss="p-2 font-semibold text-left hidden xl:table-cell"
ColumnCss="p-2 text-left font-normal hidden xl:table-cell">
<ColumnTemplate>
<div>
<i class="icon-memory-stick text-lg me-1 align-middle text-primary"></i>
<span class="align-middle">1.56GB / 64GB</span>
</div>
</ColumnTemplate>
</DataTableColumn>
<DataTableColumn TItem="NodeDetailResponse"
HeaderCss="p-2 font-semibold text-left hidden xl:table-cell"
ColumnCss="p-2 text-left font-normal hidden xl:table-cell">
<ColumnTemplate>
<div>
<i class="icon-hard-drive text-lg me-1 align-middle text-primary"></i>
<span class="align-middle">78.68GB / 1TB</span>
</div>
@{
var isFetched = Statistics.TryGetValue(context.Id, out var data);
}
@if (isFetched)
{
if (data == null)
{
<div class="flex items-center text-danger">
<i class="icon-server-off text-base me-1"></i>
<span>
API Error
</span>
</div>
}
else
{
<div class="flex flex-row">
<div class="flex items-center">
<i class="text-primary text-base me-2 icon-cpu"></i>
<span>@(Math.Round(data.Cpu.Usage))%</span>
</div>
<div class="flex items-center ms-5">
<i class="text-primary text-base me-2 icon-memory-stick"></i>
<span>
@(Math.Round((data.Memory.Total - data.Memory.Free - data.Memory.Cached) / (double)data.Memory.Total * 100))%
</span>
</div>
</div>
}
}
else
{
<div class="flex items-center text-gray-500">
<i class="icon-loader text-base me-1"></i>
<span>Loading</span>
</div>
}
</ColumnTemplate>
</DataTableColumn>
<DataTableColumn TItem="NodeDetailResponse">
@@ -147,10 +159,60 @@
{
private DataTable<NodeDetailResponse> Table;
private Dictionary<int, NodeSystemStatusResponse?> Responses = new();
private Dictionary<int, NodeSystemStatusResponse?> StatusResponses = new();
private Dictionary<int, StatisticsResponse?> Statistics = new();
private async Task<IPagedData<NodeDetailResponse>> LoadData(PaginationOptions options)
=> await ApiClient.GetJson<PagedData<NodeDetailResponse>>($"api/admin/servers/nodes?page={options.Page}&pageSize={options.PerPage}");
{
Statistics.Clear();
StatusResponses.Clear();
var result = await ApiClient.GetJson<PagedData<NodeDetailResponse>>(
$"api/admin/servers/nodes?page={options.Page}&pageSize={options.PerPage}"
);
Task.Run(async () =>
{
foreach (var item in result.Items)
{
try
{
Statistics[item.Id] = await NodeService.GetStatistics(item.Id);
}
catch (Exception e)
{
Logger.LogWarning(
"An error occured while fetching statistics for node {nodeId}: {e}",
item.Id,
e
);
Statistics[item.Id] = null;
}
await InvokeAsync(StateHasChanged);
try
{
StatusResponses[item.Id] = await NodeService.GetSystemStatus(item.Id);
}
catch (Exception e)
{
Logger.LogWarning(
"An error occured while fetching status for node {nodeId}: {e}",
item.Id,
e
);
StatusResponses[item.Id] = null;
}
await InvokeAsync(StateHasChanged);
}
});
return result;
}
private async Task Delete(NodeDetailResponse detailResponse)
{
@@ -167,35 +229,9 @@
);
}
private Task LoadNodeStatus(int node)
{
Task.Run(async () =>
{
try
{
var status = await NodeService.GetSystemStatus(node);
lock (Responses)
Responses[node] = status;
}
catch (Exception e)
{
lock (Responses)
Responses[node] = null;
}
await InvokeAsync(StateHasChanged);
});
return Task.CompletedTask;
}
private async Task ShowErrorDetails(int id)
{
NodeSystemStatusResponse? data;
lock (Responses)
data = Responses.GetValueOrDefault(id);
var data = StatusResponses.GetValueOrDefault(id);
if (data == null)
return;

View File

@@ -14,7 +14,7 @@
@attribute [Authorize(Policy = "permissions:admin.servers.nodes.update")]
<LazyLoader Load="Load">
<PageHeader Title="Update Node">
<PageHeader Title="@Node.Name">
<a href="/admin/servers/nodes" class="btn btn-secondary">
<i class="icon-chevron-left mr-1"></i>
Back
@@ -29,12 +29,16 @@
<HandleForm @ref="Form" Model="Request" OnValidSubmit="OnSubmit">
<Tabs>
<Tab Name="Overview">
<OverviewNodeUpdate Node="Node" />
</Tab>
<Tab Name="Settings">
<GeneralNodeUpdate Request="Request"/>
</Tab>
<Tab Name="Allocations">
<AllocationsNodeUpdate Node="Node" />
<AllocationsNodeUpdate Node="Node"/>
</Tab>
<Tab Name="Advanced Settings">