Added small system overview

This commit is contained in:
2025-12-25 21:55:46 +01:00
parent a2d4edc0e5
commit ca69d410f2
9 changed files with 422 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<long> GetMemoryUsageAsync()
{
using var currentProcess = Process.GetCurrentProcess();
return Task.FromResult(currentProcess.WorkingSet64);
}
public async Task<double> 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
}

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Logging.Console;
using Moonlight.Shared.Http; using Moonlight.Shared.Http;
using Moonlight.Api.Helpers; using Moonlight.Api.Helpers;
using Moonlight.Api.Services;
namespace Moonlight.Api.Startup; namespace Moonlight.Api.Startup;
@@ -19,6 +20,9 @@ public partial class Startup
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); }); builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>(); builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
} }
private static void UseBase(WebApplication application) private static void UseBase(WebApplication application)

View File

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

View File

@@ -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
<h1 class="text-xl font-semibold">Overview</h1>
<div class="text-muted-foreground">
Here you can see a quick overview of your moonlight instance
</div>
<div class="mt-8 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5">
<Card ClassName="col-span-1">
@if (IsInfoLoading || InfoResponse == null)
{
<CardContent ClassName="flex justify-center items-center">
<Spinner ClassName="size-8"/>
</CardContent>
}
else
{
<CardHeader>
<CardDescription>CPU Usage</CardDescription>
<CardTitle ClassName="text-lg">@Math.Round(InfoResponse.CpuUsage, 2)%</CardTitle>
<CardAction>
<CpuIcon ClassName="size-6 text-muted-foreground" />
</CardAction>
</CardHeader>
}
</Card>
<Card ClassName="col-span-1">
@if (IsInfoLoading || InfoResponse == null)
{
<CardContent ClassName="flex justify-center items-center">
<Spinner ClassName="size-8"/>
</CardContent>
}
else
{
<CardHeader>
<CardDescription>Memory Usage</CardDescription>
<CardTitle ClassName="text-lg">@Formatter.FormatSize(InfoResponse.MemoryUsage)</CardTitle>
<CardAction>
<MemoryStickIcon ClassName="size-6 text-muted-foreground" />
</CardAction>
</CardHeader>
}
</Card>
<Card ClassName="col-span-1">
@if (IsInfoLoading || InfoResponse == null)
{
<CardContent ClassName="flex justify-center items-center">
<Spinner ClassName="size-8"/>
</CardContent>
}
else
{
<CardHeader>
<CardDescription>Operating System</CardDescription>
<CardTitle ClassName="text-lg">@InfoResponse.OperatingSystem</CardTitle>
<CardAction>
<ComputerIcon ClassName="size-6 text-muted-foreground" />
</CardAction>
</CardHeader>
}
</Card>
<Card ClassName="col-span-1">
@if (IsInfoLoading || InfoResponse == null)
{
<CardContent ClassName="flex justify-center items-center">
<Spinner ClassName="size-8"/>
</CardContent>
}
else
{
<CardHeader>
<CardDescription>Uptime</CardDescription>
<CardTitle ClassName="text-lg">@Formatter.FormatDuration(InfoResponse.Uptime)</CardTitle>
<CardAction>
<ClockIcon ClassName="size-6 text-muted-foreground" />
</CardAction>
</CardHeader>
}
</Card>
<Card ClassName="col-span-1">
@if (IsInfoLoading || InfoResponse == null)
{
<CardContent ClassName="flex justify-center items-center">
<Spinner ClassName="size-8"/>
</CardContent>
}
else
{
<CardHeader>
<CardDescription>Version</CardDescription>
<CardTitle ClassName="text-lg">@InfoResponse.VersionName</CardTitle>
<CardAction>
<RocketIcon ClassName="size-6 text-muted-foreground" />
</CardAction>
</CardHeader>
}
</Card>
<Card ClassName="col-span-1">
@if (IsInfoLoading || InfoResponse == null)
{
<CardContent ClassName="flex justify-center items-center">
<Spinner ClassName="size-8"/>
</CardContent>
}
else
{
<CardHeader>
<CardDescription>Update Status</CardDescription>
@if (InfoResponse.IsUpToDate)
{
<CardTitle ClassName="text-lg text-green-500">Up-to-date</CardTitle>
<CardAction>
<RefreshCwIcon ClassName="size-6 text-muted-foreground" />
</CardAction>
}
else
{
<CardTitle ClassName="text-lg text-primary">Update available</CardTitle>
<CardAction ClassName="self-center">
<Button>Update</Button>
</CardAction>
}
</CardHeader>
}
</Card>
</div>
@code
{
private bool IsInfoLoading = true;
private SystemInfoResponse? InfoResponse;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if(!firstRender)
return;
InfoResponse = await HttpClient.GetFromJsonAsync<SystemInfoResponse>("api/admin/system/info", Constants.SerializerOptions);
IsInfoLoading = false;
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -80,6 +80,14 @@
IsExactPath = true IsExactPath = true
}, },
new() new()
{
Name = "Overview",
IconType = typeof(LayoutDashboardIcon),
Path = "/admin",
IsExactPath = true,
Group = "Admin"
},
new()
{ {
Name = "Users", Name = "Users",
IconType = typeof(UsersRoundIcon), IconType = typeof(UsersRoundIcon),
@@ -87,6 +95,14 @@
IsExactPath = false, IsExactPath = false,
Group = "Admin" Group = "Admin"
}, },
new()
{
Name = "Settings",
IconType = typeof(SettingsIcon),
Path = "/admin/settings",
IsExactPath = false,
Group = "Admin"
}
]); ]);
Navigation.LocationChanged += OnLocationChanged; Navigation.LocationChanged += OnLocationChanged;

View File

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

View File

@@ -1,6 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;
using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Auth;
using Moonlight.Shared.Http.Responses.Users; using Moonlight.Shared.Http.Responses.Users;
@@ -11,6 +12,7 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(ClaimResponse[]))] [JsonSerializable(typeof(ClaimResponse[]))]
[JsonSerializable(typeof(SchemeResponse[]))] [JsonSerializable(typeof(SchemeResponse[]))]
[JsonSerializable(typeof(UserResponse))] [JsonSerializable(typeof(UserResponse))]
[JsonSerializable(typeof(SystemInfoResponse))]
[JsonSerializable(typeof(PagedData<UserResponse>))] [JsonSerializable(typeof(PagedData<UserResponse>))]
public partial class SerializationContext : JsonSerializerContext public partial class SerializationContext : JsonSerializerContext
{ {