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

@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using MoonlightServers.ApiServer.Database.Entities;
using MoonlightServers.ApiServer.Services;
using MoonlightServers.Shared.Http.Responses.Admin.Nodes.Statistics;
namespace MoonlightServers.ApiServer.Http.Controllers.Admin.Nodes;
[ApiController]
[Route("api/admin/servers/nodes")]
[Authorize(Policy = "permissions:admin.servers.nodes.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("{nodeId:int}/statistics")]
public async Task<StatisticsResponse> Get([FromRoute] int nodeId)
{
var node = await GetNode(nodeId);
var statistics = await NodeService.GetStatistics(node);
return new()
{
Cpu = new()
{
Model = statistics.Cpu.Model,
Usage = statistics.Cpu.Usage,
UsagePerCore = statistics.Cpu.UsagePerCore
},
Memory = new()
{
Available = statistics.Memory.Available,
Total = statistics.Memory.Total,
Cached = statistics.Memory.Cached,
Free = statistics.Memory.Free,
SwapFree = statistics.Memory.SwapFree,
SwapTotal = statistics.Memory.SwapTotal
},
Disks = statistics.Disks.Select(x => new StatisticsResponse.DiskData()
{
Device = x.Device,
DiskFree = x.DiskFree,
DiskTotal = x.DiskTotal,
InodesFree = x.InodesFree,
InodesTotal = x.InodesTotal,
MountPath = x.MountPath
}).ToArray()
};
}
[HttpGet("{nodeId:int}/statistics/docker")]
public async Task<DockerStatisticsResponse> GetDocker([FromRoute] int nodeId)
{
var node = await GetNode(nodeId);
var statistics = await NodeService.GetDockerStatistics(node);
return new()
{
BuildCacheReclaimable = statistics.BuildCacheReclaimable,
BuildCacheUsed = statistics.BuildCacheUsed,
ContainersReclaimable = statistics.ContainersReclaimable,
ContainersUsed = statistics.ContainersUsed,
ImagesReclaimable = statistics.ImagesReclaimable,
ImagesUsed = statistics.ImagesUsed,
Version = statistics.Version
};
}
private async Task<Node> GetNode(int nodeId)
{
var result = await NodeRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == nodeId);
if (result == null)
throw new HttpApiException("A node with this id could not be found", 404);
return result;
}
}

View File

@@ -49,16 +49,10 @@ public class NodeService
#region Statistics
public async Task<StatisticsApplicationResponse> GetApplicationStatistics(Node node)
public async Task<StatisticsResponse> GetStatistics(Node node)
{
using var apiClient = CreateApiClient(node);
return await apiClient.GetJson<StatisticsApplicationResponse>("api/statistics/application");
}
public async Task<StatisticsHostResponse> GetHostStatistics(Node node)
{
using var apiClient = CreateApiClient(node);
return await apiClient.GetJson<StatisticsHostResponse>("api/statistics/host");
return await apiClient.GetJson<StatisticsResponse>("api/statistics");
}
public async Task<StatisticsDockerResponse> GetDockerStatistics(Node node)

View File

@@ -1,11 +1,21 @@
using System.Runtime.InteropServices;
using Mono.Unix.Native;
using MoonCore.Attributes;
using MoonCore.Helpers;
using MoonlightServers.Daemon.Models;
namespace MoonlightServers.Daemon.Helpers;
[Singleton]
public class HostSystemHelper
{
private readonly ILogger<HostSystemHelper> Logger;
public HostSystemHelper(ILogger<HostSystemHelper> logger)
{
Logger = logger;
}
public string GetOsName()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
@@ -45,5 +55,209 @@ public class HostSystemHelper
return "Unknown";
}
#region CPU Usage
public async Task<CpuUsageDetails> GetCpuUsage()
{
var result = new CpuUsageDetails();
var perCoreUsages = new List<double>();
// Initial read
var (cpuLastStats, cpuLastSums) = await ReadAllCpuStats();
await Task.Delay(1000);
// Second read
var (cpuNowStats, cpuNowSums) = await ReadAllCpuStats();
for (var i = 0; i < cpuNowStats.Length; i++)
{
var cpuDelta = cpuNowSums[i] - cpuLastSums[i];
var cpuIdle = cpuNowStats[i][3] - cpuLastStats[i][3];
var cpuUsed = cpuDelta - cpuIdle;
var usage = 100.0 * cpuUsed / cpuDelta;
if (i == 0)
result.OverallUsage = usage;
else
perCoreUsages.Add(usage);
}
result.PerCoreUsage = perCoreUsages.ToArray();
// Get model name
var cpuInfoLines = await File.ReadAllLinesAsync("/proc/cpuinfo");
var modelLine = cpuInfoLines.FirstOrDefault(x => x.StartsWith("model name"));
result.Model = modelLine?.Split(":")[1].Trim() ?? "N/A";
return result;
}
private async Task<(long[][] cpuStatsList, long[] cpuSums)> ReadAllCpuStats()
{
var lines = await File.ReadAllLinesAsync("/proc/stat");
lines = lines.Where(line => line.StartsWith("cpu"))
.TakeWhile(line => line.StartsWith("cpu")) // Ensures only CPU lines are read
.ToArray();
var statsList = new List<long[]>();
var sumList = new List<long>();
foreach (var line in lines)
{
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Skip(1) // Skip the "cpu" label
.ToArray();
var cpuTimes = parts
.Select(long.Parse)
.ToArray();
var sum = cpuTimes.Sum();
statsList.Add(cpuTimes);
sumList.Add(sum);
}
return (statsList.ToArray(), sumList.ToArray());
}
#endregion
#region Memory
public async Task ClearCachedMemory()
{
await File.WriteAllTextAsync("/proc/sys/vm/drop_caches", "3");
}
public async Task<MemoryUsageDetails> GetMemoryUsage()
{
var details = new MemoryUsageDetails();
var lines = await File.ReadAllLinesAsync("/proc/meminfo");
foreach (var line in lines)
{
// We want to ignore all non kilobyte values
if (!line.Contains("kB"))
continue;
// Split the line up so we can extract the id and the value
// to map it to the model field
var parts = line.Split(":");
var id = parts[0];
var value = parts[1]
.Replace("kB", "")
.Trim();
if (!long.TryParse(value, out var longValue))
continue;
var bytes = ByteConverter.FromKiloBytes(longValue).Bytes;
switch (id)
{
case "MemTotal":
details.Total = bytes;
break;
case "MemFree":
details.Free = bytes;
break;
case "MemAvailable":
details.Available = bytes;
break;
case "Cached":
details.Cached = bytes;
break;
case "SwapTotal":
details.SwapTotal = bytes;
break;
case "SwapFree":
details.SwapFree = bytes;
break;
}
}
return details;
}
#endregion
#region Disks
public async Task<DiskUsageDetails[]> GetDiskUsages()
{
var details = new List<DiskUsageDetails>();
// First we need to check which mounts actually exist
var diskDevices = new Dictionary<string, string>();
string[] ignoredMounts = ["/boot/efi", "/boot"];
var mountLines = await File.ReadAllLinesAsync("/proc/mounts");
foreach (var mountLine in mountLines)
{
var parts = mountLine.Split(" ");
var device = parts[0];
var mountedAt = parts[1];
// We only want to handle mounted physical devices
if (!device.StartsWith("/dev/"))
continue;
// Ignore certain mounts which we dont want to show
if (ignoredMounts.Contains(mountedAt))
continue;
diskDevices.Add(device, mountedAt);
}
foreach (var diskMount in diskDevices)
{
var device = diskMount.Key;
var mount = diskMount.Value;
var statusCode = Syscall.statvfs(mount, out var statvfs);
if (statusCode != 0)
{
var error = Stdlib.GetLastError();
Logger.LogError(
"An error occured while checking disk stats for mount {mount}: {error}",
mount,
error
);
continue;
}
// Source: https://man7.org/linux/man-pages/man3/statvfs.3.html
var detail = new DiskUsageDetails()
{
Device = device,
MountPath = mount,
DiskTotal = statvfs.f_blocks * statvfs.f_frsize,
DiskFree = statvfs.f_bfree * statvfs.f_frsize,
InodesTotal = statvfs.f_files,
InodesFree = statvfs.f_ffree
};
details.Add(detail);
}
return details.ToArray();
}
#endregion
}

View File

@@ -1,36 +0,0 @@
using System.Diagnostics;
using MoonCore.Attributes;
namespace MoonlightServers.Daemon.Helpers;
[Singleton]
public class OwnProcessHelper
{
public long GetMemoryUsage()
{
var process = Process.GetCurrentProcess();
var bytes = process.PrivateMemorySize64;
return bytes;
}
public TimeSpan GetUptime()
{
var process = Process.GetCurrentProcess();
var uptime = DateTime.Now - process.StartTime;
return uptime;
}
public int CpuUsage()
{
var process = Process.GetCurrentProcess();
var cpuTime = process.TotalProcessorTime;
var wallClockTime = DateTime.UtcNow - process.StartTime.ToUniversalTime();
var cpuUsage = (int)(100.0 * cpuTime.TotalMilliseconds / wallClockTime.TotalMilliseconds /
Environment.ProcessorCount);
return cpuUsage;
}
}

View File

@@ -1,32 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
namespace MoonlightServers.Daemon.Http.Controllers.Statistics;
// This controller hosts endpoints for the statistics for the daemon application itself
[Authorize]
[ApiController]
[Route("api/statistics/application")]
public class StatisticsApplicationController : Controller
{
private readonly OwnProcessHelper ProcessHelper;
public StatisticsApplicationController(OwnProcessHelper processHelper)
{
ProcessHelper = processHelper;
}
[HttpGet]
public async Task<StatisticsApplicationResponse> Get()
{
return new StatisticsApplicationResponse()
{
Uptime = ProcessHelper.GetUptime(),
MemoryUsage = ProcessHelper.GetMemoryUsage(),
CpuUsage = ProcessHelper.CpuUsage()
};
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
namespace MoonlightServers.Daemon.Http.Controllers.Statistics;
[Authorize]
[ApiController]
[Route("api/statistics")]
public class StatisticsController : Controller
{
private readonly HostSystemHelper HostSystemHelper;
public StatisticsController(HostSystemHelper hostSystemHelper)
{
HostSystemHelper = hostSystemHelper;
}
[HttpGet]
public async Task<StatisticsResponse> Get()
{
var response = new StatisticsResponse();
var cpuUsage = await HostSystemHelper.GetCpuUsage();
response.Cpu.Model = cpuUsage.Model;
response.Cpu.Usage = cpuUsage.OverallUsage;
response.Cpu.UsagePerCore = cpuUsage.PerCoreUsage;
var memoryUsage = await HostSystemHelper.GetMemoryUsage();
response.Memory.Available = memoryUsage.Available;
response.Memory.Cached = memoryUsage.Cached;
response.Memory.Free = memoryUsage.Free;
response.Memory.Total = memoryUsage.Total;
response.Memory.SwapTotal = memoryUsage.SwapTotal;
response.Memory.SwapFree = memoryUsage.SwapFree;
var diskDetails = await HostSystemHelper.GetDiskUsages();
response.Disks = diskDetails.Select(x => new StatisticsResponse.DiskData()
{
Device = x.Device,
MountPath = x.MountPath,
DiskFree = x.DiskFree,
DiskTotal = x.DiskTotal,
InodesFree = x.InodesFree,
InodesTotal = x.InodesTotal
}).ToArray();
return response;
}
}

View File

@@ -1,30 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
namespace MoonlightServers.Daemon.Http.Controllers.Statistics;
// This controller hosts endpoints for the statistics for host system the daemon runs on
[Authorize]
[ApiController]
[Route("api/statistics/host")]
public class StatisticsHostController : Controller
{
private readonly HostSystemHelper HostSystemHelper;
public StatisticsHostController(HostSystemHelper hostSystemHelper)
{
HostSystemHelper = hostSystemHelper;
}
[HttpGet]
public async Task<StatisticsHostResponse> Get()
{
return new()
{
OperatingSystem = HostSystemHelper.GetOsName()
};
}
}

View File

@@ -0,0 +1,8 @@
namespace MoonlightServers.Daemon.Models;
public class CpuUsageDetails
{
public string Model { get; set; }
public double OverallUsage { get; set; }
public double[] PerCoreUsage { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace MoonlightServers.Daemon.Models;
public class DiskUsageDetails
{
public string Device { get; set; }
public string MountPath { get; set; }
public ulong DiskTotal { get; set; }
public ulong DiskFree { get; set; }
public ulong InodesTotal { get; set; }
public ulong InodesFree { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace MoonlightServers.Daemon.Models;
public class MemoryUsageDetails
{
public long Total { get; set; }
public long Available { get; set; }
public long Free { get; set; }
public long Cached { get; set; }
public long SwapTotal { get; set; }
public long SwapFree { get; set; }
}

View File

@@ -1,8 +0,0 @@
namespace MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
public class StatisticsApplicationResponse
{
public int CpuUsage { get; set; }
public long MemoryUsage { get; set; }
public TimeSpan Uptime { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
public class StatisticsHostResponse
{
public string OperatingSystem { get; set; }
}

View File

@@ -0,0 +1,36 @@
namespace MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
public class StatisticsResponse
{
public CpuData Cpu { get; set; } = new();
public MemoryData Memory { get; set; } = new();
public DiskData[] Disks { get; set; } = [];
public record DiskData
{
public string Device { get; set; }
public string MountPath { get; set; }
public ulong DiskTotal { get; set; }
public ulong DiskFree { get; set; }
public ulong InodesTotal { get; set; }
public ulong InodesFree { get; set; }
}
public record MemoryData
{
public long Total { get; set; }
public long Available { get; set; }
public long Free { get; set; }
public long Cached { get; set; }
public long SwapTotal { get; set; }
public long SwapFree { get; set; }
}
public record CpuData
{
public string Model { get; set; }
public double Usage { get; set; }
public double[] UsagePerCore { get; set; }
}
}

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,7 +33,7 @@
<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">
@@ -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">

View File

@@ -0,0 +1,15 @@
namespace MoonlightServers.Shared.Http.Responses.Admin.Nodes.Statistics;
public class DockerStatisticsResponse
{
public string Version { get; set; }
public long ImagesUsed { get; set; }
public long ImagesReclaimable { get; set; }
public long ContainersUsed { get; set; }
public long ContainersReclaimable { get; set; }
public long BuildCacheUsed { get; set; }
public long BuildCacheReclaimable { get; set; }
}

View File

@@ -0,0 +1,36 @@
namespace MoonlightServers.Shared.Http.Responses.Admin.Nodes.Statistics;
public class StatisticsResponse
{
public CpuData Cpu { get; set; }
public MemoryData Memory { get; set; }
public DiskData[] Disks { get; set; }
public record DiskData
{
public string Device { get; set; }
public string MountPath { get; set; }
public ulong DiskTotal { get; set; }
public ulong DiskFree { get; set; }
public ulong InodesTotal { get; set; }
public ulong InodesFree { get; set; }
}
public record MemoryData
{
public long Total { get; set; }
public long Available { get; set; }
public long Free { get; set; }
public long Cached { get; set; }
public long SwapTotal { get; set; }
public long SwapFree { get; set; }
}
public record CpuData
{
public string Model { get; set; }
public double Usage { get; set; }
public double[] UsagePerCore { get; set; }
}
}