From ca69d410f2821bd013124a5c84a9163bb8d656a1 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 25 Dec 2025 21:55:46 +0100 Subject: [PATCH] Added small system overview --- Moonlight.Api/Helpers/OsHelper.cs | 98 +++++++++++ .../Controllers/Admin/SystemController.cs | 33 ++++ Moonlight.Api/Services/ApplicationService.cs | 64 +++++++ Moonlight.Api/Startup/Startup.Base.cs | 4 + Moonlight.Frontend/Formatter.cs | 46 ++++++ .../UI/Admin/Views/Overview.razor | 156 ++++++++++++++++++ .../UI/{ => Shared}/Partials/AppSidebar.razor | 16 ++ .../Responses/Admin/SystemInfoResponse.cs | 3 + Moonlight.Shared/Http/SerializationContext.cs | 2 + 9 files changed, 422 insertions(+) create mode 100644 Moonlight.Api/Helpers/OsHelper.cs create mode 100644 Moonlight.Api/Http/Controllers/Admin/SystemController.cs create mode 100644 Moonlight.Api/Services/ApplicationService.cs create mode 100644 Moonlight.Frontend/Formatter.cs create mode 100644 Moonlight.Frontend/UI/Admin/Views/Overview.razor rename Moonlight.Frontend/UI/{ => Shared}/Partials/AppSidebar.razor (88%) create mode 100644 Moonlight.Shared/Http/Responses/Admin/SystemInfoResponse.cs diff --git a/Moonlight.Api/Helpers/OsHelper.cs b/Moonlight.Api/Helpers/OsHelper.cs new file mode 100644 index 00000000..c53c37bf --- /dev/null +++ b/Moonlight.Api/Helpers/OsHelper.cs @@ -0,0 +1,98 @@ +using System.Runtime.InteropServices; + +namespace Moonlight.Api.Helpers; + +public class OsHelper +{ + public static string GetName() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return GetWindowsVersion(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return GetLinuxVersion(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return "Goofy OS"; + + return "Unknown OS"; + } + + private static string GetWindowsVersion() + { + var version = Environment.OSVersion.Version; + + // Windows 11 is version 10.0 build 22000+ + if (version.Major == 10 && version.Build >= 22000) + return $"Windows 11 ({version.Build})"; + + if (version.Major == 10) + return $"Windows 10 ({version.Build})"; + + if (version.Major == 6 && version.Minor == 3) + return "Windows 8.1"; + + if (version.Major == 6 && version.Minor == 2) + return "Windows 8"; + + if (version.Major == 6 && version.Minor == 1) + return "Windows 7"; + + return $"Windows {version.Major}.{version.Minor}"; + } + + private static string GetLinuxVersion() + { + try + { + // Read /etc/os-release, should work everywhere + if (File.Exists("/etc/os-release")) + { + var lines = File.ReadAllLines("/etc/os-release"); + string? name = null; + string? version = null; + + foreach (var line in lines) + { + if (line.StartsWith("NAME=")) + name = line.Substring(5).Trim('"'); + else if (line.StartsWith("VERSION_ID=")) + version = line.Substring(11).Trim('"'); + } + + if (!string.IsNullOrEmpty(name)) + { + return string.IsNullOrEmpty(version) ? name : $"{name} {version}"; + } + } + + //If for some weird reason it still uses lsb release + if (File.Exists("/etc/lsb-release")) + { + var lines = File.ReadAllLines("/etc/lsb-release"); + string? name = null; + string? version = null; + + foreach (var line in lines) + { + if (line.StartsWith("DISTRIB_ID=")) + name = line.Substring(11); + else if (line.StartsWith("DISTRIB_RELEASE=")) + version = line.Substring(16); + } + + if (!string.IsNullOrEmpty(name)) + { + return string.IsNullOrEmpty(version) ? name : $"{name} {version}"; + } + } + } + catch + { + // Ignore + } + + // Fallback + return $"Linux {Environment.OSVersion.Version}"; + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/SystemController.cs b/Moonlight.Api/Http/Controllers/Admin/SystemController.cs new file mode 100644 index 00000000..96671292 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/SystemController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using Moonlight.Api.Services; +using Moonlight.Shared.Http.Responses.Admin; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[ApiController] +[Route("api/admin/system")] +public class SystemController : Controller +{ + private readonly ApplicationService ApplicationService; + + public SystemController(ApplicationService applicationService) + { + ApplicationService = applicationService; + } + + [HttpGet("info")] + public async Task> GetInfoAsync() + { + var cpuUsage = await ApplicationService.GetCpuUsageAsync(); + var memoryUsage = await ApplicationService.GetMemoryUsageAsync(); + + return new SystemInfoResponse( + cpuUsage, + memoryUsage, + ApplicationService.OperatingSystem, + DateTimeOffset.UtcNow - ApplicationService.StartedAt, + ApplicationService.VersionName, + ApplicationService.IsUpToDate + ); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Services/ApplicationService.cs b/Moonlight.Api/Services/ApplicationService.cs new file mode 100644 index 00000000..3a002ab1 --- /dev/null +++ b/Moonlight.Api/Services/ApplicationService.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Moonlight.Api.Helpers; + +namespace Moonlight.Api.Services; + +public class ApplicationService : IHostedLifecycleService +{ + public DateTimeOffset StartedAt { get; private set; } + public string VersionName { get; private set; } = "N/A"; + public bool IsUpToDate { get; set; } = true; + public string OperatingSystem { get; private set; } = "N/A"; + + public Task GetMemoryUsageAsync() + { + using var currentProcess = Process.GetCurrentProcess(); + return Task.FromResult(currentProcess.WorkingSet64); + } + + public async Task GetCpuUsageAsync() + { + using var currentProcess = Process.GetCurrentProcess(); + + // Get initial values + var startCpuTime = currentProcess.TotalProcessorTime; + var startTime = DateTime.UtcNow; + + // Wait a bit to calculate the diff + await Task.Delay(500); + + // New values + var endCpuTime = currentProcess.TotalProcessorTime; + var endTime = DateTime.UtcNow; + + // Calculate CPU usage + var cpuUsedMs = (endCpuTime - startCpuTime).TotalMilliseconds; + var totalMsPassed = (endTime - startTime).TotalMilliseconds; + var cpuUsagePercent = (cpuUsedMs / (Environment.ProcessorCount * totalMsPassed)) * 100; + + return Math.Round(cpuUsagePercent, 2); + } + + public async Task StartedAsync(CancellationToken cancellationToken) + { + StartedAt = DateTimeOffset.UtcNow; + + // TODO: Update / version check + + VersionName = "v2.1.0 (a2d4edc0e5)"; + IsUpToDate = true; + + OperatingSystem = OsHelper.GetName(); + } + + #region Unused + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + #endregion +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index 1acba450..4d417ec3 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Console; using Moonlight.Shared.Http; using Moonlight.Api.Helpers; +using Moonlight.Api.Services; namespace Moonlight.Api.Startup; @@ -19,6 +20,9 @@ public partial class Startup builder.Logging.ClearProviders(); builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); }); builder.Logging.AddConsoleFormatter(); + + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); } private static void UseBase(WebApplication application) diff --git a/Moonlight.Frontend/Formatter.cs b/Moonlight.Frontend/Formatter.cs new file mode 100644 index 00000000..6fa70136 --- /dev/null +++ b/Moonlight.Frontend/Formatter.cs @@ -0,0 +1,46 @@ +namespace Moonlight.Frontend; + +public static class Formatter +{ + public 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]}"; + } + + public 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"; + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Overview.razor b/Moonlight.Frontend/UI/Admin/Views/Overview.razor new file mode 100644 index 00000000..aa664bc3 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Overview.razor @@ -0,0 +1,156 @@ +@page "/admin" +@using LucideBlazor +@using Moonlight.Shared.Http.Responses.Admin +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Spinners + +@inject HttpClient HttpClient + +

Overview

+
+ Here you can see a quick overview of your moonlight instance +
+ +
+ + @if (IsInfoLoading || InfoResponse == null) + { + + + + } + else + { + + CPU Usage + @Math.Round(InfoResponse.CpuUsage, 2)% + + + + + } + + + + @if (IsInfoLoading || InfoResponse == null) + { + + + + } + else + { + + Memory Usage + @Formatter.FormatSize(InfoResponse.MemoryUsage) + + + + + } + + + + @if (IsInfoLoading || InfoResponse == null) + { + + + + } + else + { + + Operating System + @InfoResponse.OperatingSystem + + + + + } + + + + @if (IsInfoLoading || InfoResponse == null) + { + + + + } + else + { + + Uptime + @Formatter.FormatDuration(InfoResponse.Uptime) + + + + + } + + + + @if (IsInfoLoading || InfoResponse == null) + { + + + + } + else + { + + Version + @InfoResponse.VersionName + + + + + } + + + + @if (IsInfoLoading || InfoResponse == null) + { + + + + } + else + { + + Update Status + @if (InfoResponse.IsUpToDate) + { + Up-to-date + + + + } + else + { + Update available + + + + } + + } + +
+ +@code +{ + private bool IsInfoLoading = true; + private SystemInfoResponse? InfoResponse; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if(!firstRender) + return; + + InfoResponse = await HttpClient.GetFromJsonAsync("api/admin/system/info", Constants.SerializerOptions); + IsInfoLoading = false; + + await InvokeAsync(StateHasChanged); + } +} diff --git a/Moonlight.Frontend/UI/Partials/AppSidebar.razor b/Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor similarity index 88% rename from Moonlight.Frontend/UI/Partials/AppSidebar.razor rename to Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor index a1329bf6..8ffef144 100644 --- a/Moonlight.Frontend/UI/Partials/AppSidebar.razor +++ b/Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor @@ -80,6 +80,14 @@ IsExactPath = true }, new() + { + Name = "Overview", + IconType = typeof(LayoutDashboardIcon), + Path = "/admin", + IsExactPath = true, + Group = "Admin" + }, + new() { Name = "Users", IconType = typeof(UsersRoundIcon), @@ -87,6 +95,14 @@ IsExactPath = false, Group = "Admin" }, + new() + { + Name = "Settings", + IconType = typeof(SettingsIcon), + Path = "/admin/settings", + IsExactPath = false, + Group = "Admin" + } ]); Navigation.LocationChanged += OnLocationChanged; diff --git a/Moonlight.Shared/Http/Responses/Admin/SystemInfoResponse.cs b/Moonlight.Shared/Http/Responses/Admin/SystemInfoResponse.cs new file mode 100644 index 00000000..e807e2a1 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Admin/SystemInfoResponse.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Shared.Http.Responses.Admin; + +public record SystemInfoResponse(double CpuUsage, long MemoryUsage, string OperatingSystem, TimeSpan Uptime, string VersionName, bool IsUpToDate); \ No newline at end of file diff --git a/Moonlight.Shared/Http/SerializationContext.cs b/Moonlight.Shared/Http/SerializationContext.cs index c29c69a1..73e451ab 100644 --- a/Moonlight.Shared/Http/SerializationContext.cs +++ b/Moonlight.Shared/Http/SerializationContext.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Responses; +using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Users; @@ -11,6 +12,7 @@ namespace Moonlight.Shared.Http; [JsonSerializable(typeof(ClaimResponse[]))] [JsonSerializable(typeof(SchemeResponse[]))] [JsonSerializable(typeof(UserResponse))] +[JsonSerializable(typeof(SystemInfoResponse))] [JsonSerializable(typeof(PagedData))] public partial class SerializationContext : JsonSerializerContext {