Added diagnose frontend and backend implementation

This commit is contained in:
2025-12-27 23:32:36 +01:00
parent be3cdb8235
commit e1c0645428
11 changed files with 380 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/system/diagnose")]
public class DiagnoseController : Controller
{
private readonly DiagnoseService DiagnoseService;
public DiagnoseController(DiagnoseService diagnoseService)
{
DiagnoseService = diagnoseService;
}
[HttpGet]
public async Task<ActionResult<DiagnoseResultResponse[]>> GetAsync()
{
var results = await DiagnoseService.DiagnoseAsync();
return results
.OrderBy(x => x.Level)
.MapToResult()
.ToArray();
}
}

View File

@@ -0,0 +1,33 @@
using Moonlight.Api.Interfaces;
using Moonlight.Api.Models;
using Moonlight.Api.Services;
namespace Moonlight.Api.Implementations;
public sealed class UpdateDiagnoseProvider : IDiagnoseProvider
{
private readonly ApplicationService ApplicationService;
public UpdateDiagnoseProvider(ApplicationService applicationService)
{
ApplicationService = applicationService;
}
public Task<DiagnoseResult[]> DiagnoseAsync()
{
if (ApplicationService.IsUpToDate)
return Task.FromResult<DiagnoseResult[]>([]);
return Task.FromResult<DiagnoseResult[]>([
new DiagnoseResult(
DiagnoseLevel.Warning,
"Instance is not up-to-date",
["Moonlight", "Update Check"],
"Update your moonlight instance to receive bug fixes, new features and security patches. Update button can be found in the overview",
null,
"/admin",
null
)
]);
}
}

View File

@@ -0,0 +1,8 @@
using Moonlight.Api.Models;
namespace Moonlight.Api.Interfaces;
public interface IDiagnoseProvider
{
public Task<DiagnoseResult[]> DiagnoseAsync();
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class DiagnoseResultMapper
{
public static partial IEnumerable<DiagnoseResultResponse> MapToResult(this IEnumerable<DiagnoseResult> results);
}

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Api.Models;
public record DiagnoseResult(DiagnoseLevel Level, string Title, string[] Tags, string? Message, string? StackStrace, string? SolutionUrl, string? ReportUrl);
public enum DiagnoseLevel
{
Error = 0,
Warning = 1,
Healthy = 2
}

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services;
public class DiagnoseService
{
private readonly IEnumerable<IDiagnoseProvider> Providers;
private readonly ILogger<DiagnoseService> Logger;
public DiagnoseService(IEnumerable<IDiagnoseProvider> providers, ILogger<DiagnoseService> logger)
{
Providers = providers;
Logger = logger;
}
public async Task<DiagnoseResult[]> DiagnoseAsync()
{
var results = new List<DiagnoseResult>();
foreach (var provider in Providers)
{
try
{
results.AddRange(
await provider.DiagnoseAsync()
);
}
catch (Exception e)
{
Logger.LogError(e, "An unhandled error occured while processing provider");
}
}
return results.ToArray();
}
}

View File

@@ -4,6 +4,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
using Moonlight.Shared.Http;
using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Services;
namespace Moonlight.Api.Startup;
@@ -23,6 +25,10 @@ public partial class Startup
builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
}
private static void UseBase(WebApplication application)

View File

@@ -0,0 +1,228 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Accordions
@using ShadcnBlazor.Alerts
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Emptys
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Spinners
@inject HttpClient HttpClient
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5 mt-5">
<div class="col-span-1">
<Card>
<CardHeader>
<CardTitle>Automatic diagnosis</CardTitle>
<CardDescription>
Use a diagnostic report to share configuration details and errors with Moonlight developers, with
sensitive data automatically censored.
</CardDescription>
</CardHeader>
<CardContent ClassName="flex flex-col gap-y-5">
<Alert
ClassName="w-full flex flex-row items-center gap-3 border-yellow-500/80 bg-yellow-500/5 text-yellow-500">
<div class="flex shrink-0 items-center">
<TriangleAlertIcon ClassName="size-6 text-yellow-500/60"/>
</div>
<div class="flex flex-1 items-center justify-between gap-4">
<div class="flex flex-col gap-0.5">
<AlertTitle>Notice</AlertTitle>
<AlertDescription ClassName="text-yellow-500/80">
Only share these reports with the moonlight developers or the corresponding plugin
developers.
Even though we do our best to censor sensitive data it may still contain information you
dont want a random person on the internet to know
</AlertDescription>
</div>
</div>
</Alert>
</CardContent>
<CardFooter ClassName="justify-end">
<WButtom OnClick="DiagnoseAsync">
<StethoscopeIcon/>
Start diagnostics
</WButtom>
</CardFooter>
</Card>
</div>
<div class="col-span-1">
<Card>
<CardContent ClassName="flex justify-center items-center">
@if (IsLoading)
{
<Spinner ClassName="size-10"/>
}
else
{
if (HasDiagnosed)
{
if (Entries.Length == 0)
{
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<SearchIcon/>
</EmptyMedia>
<EmptyTitle>No results available</EmptyTitle>
<EmptyDescription>
Diagnosis didnt return any results
</EmptyDescription>
</EmptyHeader>
</Empty>
}
else
{
<Accordion
ClassName="w-full"
Type="AccordionType.Single">
@for (var i = 0; i < Entries.Length; i++)
{
var entry = Entries[i];
var textColor = entry.Level switch
{
DiagnoseLevel.Error => "text-destructive",
DiagnoseLevel.Warning => "text-yellow-300",
DiagnoseLevel.Healthy => "text-green-500"
};
<AccordionItem
ClassName="overflow-hidden border bg-background px-4 first:rounded-t-lg last:rounded-b-lg last:border-b"
Value="@($"diagnoseEntry{i}")">
<AccordionTrigger className="hover:no-underline">
<div class="flex items-center gap-3">
@switch (entry.Level)
{
case DiagnoseLevel.Error:
<CircleXIcon ClassName="@textColor"/>
break;
case DiagnoseLevel.Warning:
<TriangleAlertIcon ClassName="@textColor"/>
break;
case DiagnoseLevel.Healthy:
<CircleCheckIcon ClassName="@textColor"/>
break;
}
<div class="flex flex-col items-start text-left">
<span class="@textColor">
@entry.Title
</span>
<span class="text-sm text-muted-foreground">
@(string.Join(" / ", entry.Tags))
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent ClassName="ps-7">
<div class="text-muted-foreground flex flex-col gap-y-3">
@if (!string.IsNullOrWhiteSpace(entry.StackStrace))
{
<div
class="rounded-xl p-2.5 bg-black max-h-36 overflow-auto scrollbar-thin">
@entry.StackStrace
</div>
}
@if (!string.IsNullOrWhiteSpace(entry.Message))
{
<p>
@entry.Message
</p>
}
@if (
!string.IsNullOrWhiteSpace(entry.ReportUrl) ||
!string.IsNullOrWhiteSpace(entry.StackStrace) ||
!string.IsNullOrWhiteSpace(entry.SolutionUrl)
)
{
<div class="flex justify-end gap-x-1">
@if (!string.IsNullOrWhiteSpace(entry.StackStrace))
{
<Button Variant="ButtonVariant.Outline">
<CopyIcon/>
Copy
</Button>
}
@if (!string.IsNullOrWhiteSpace(entry.SolutionUrl))
{
<Button Variant="ButtonVariant.Outline">
<Slot>
<a href="@entry.SolutionUrl" @attributes="context">
<WrenchIcon/>
Show suggested solution
</a>
</Slot>
</Button>
}
@if (!string.IsNullOrWhiteSpace(entry.ReportUrl))
{
<Button Variant="ButtonVariant.Outline">
<Slot>
<a href="@entry.ReportUrl" @attributes="context">
<GitBranchIcon/>
Report on Github
</a>
</Slot>
</Button>
}
</div>
}
</div>
</AccordionContent>
</AccordionItem>
}
</Accordion>
}
}
else
{
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<CircleQuestionMarkIcon/>
</EmptyMedia>
<EmptyTitle>No results available yet</EmptyTitle>
<EmptyDescription>
Press the start button to start the automatic diagnosis
</EmptyDescription>
</EmptyHeader>
</Empty>
}
}
</CardContent>
</Card>
</div>
</div>
@code
{
private bool IsLoading = false;
private bool HasDiagnosed = false;
private DiagnoseResultResponse[] Entries;
private async Task DiagnoseAsync()
{
IsLoading = true;
HasDiagnosed = false;
await InvokeAsync(StateHasChanged);
var results = await HttpClient.GetFromJsonAsync<DiagnoseResultResponse[]>("api/admin/system/diagnose");
Entries = results ?? [];
IsLoading = false;
HasDiagnosed = true;
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -43,4 +43,7 @@
</CardFooter>
</Card>
</TabsContent>
<TabsContent Value="diagnose">
<Diagnose />
</TabsContent>
</Tabs>

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Shared.Http.Responses.Admin;
public record DiagnoseResultResponse(DiagnoseLevel Level, string Title, string[] Tags, string? Message, string? StackStrace, string? SolutionUrl, string? ReportUrl);
public enum DiagnoseLevel
{
Error = 0,
Warning = 1,
Healthy = 2
}

View File

@@ -11,6 +11,7 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(UpdateUserRequest))]
[JsonSerializable(typeof(ClaimResponse[]))]
[JsonSerializable(typeof(SchemeResponse[]))]
[JsonSerializable(typeof(DiagnoseResultResponse[]))]
[JsonSerializable(typeof(UserResponse))]
[JsonSerializable(typeof(SystemInfoResponse))]
[JsonSerializable(typeof(PagedData<UserResponse>))]