Merge pull request #440 from Moonlight-Panel/v2_ChangeArchitecture_AddDiagnose

Added diagnose system
This commit is contained in:
2025-05-17 19:41:32 +02:00
committed by GitHub
13 changed files with 410 additions and 4 deletions

View File

@@ -0,0 +1,33 @@
using System.IO.Compression;
using System.Text;
namespace Moonlight.ApiServer.Extensions;
public static class ZipArchiveExtensions
{
public static async Task AddBinary(this ZipArchive archive, string name, byte[] bytes)
{
var entry = archive.CreateEntry(name);
await using var dataStream = entry.Open();
await dataStream.WriteAsync(bytes);
await dataStream.FlushAsync();
}
public static async Task AddText(this ZipArchive archive, string name, string content)
{
var data = Encoding.UTF8.GetBytes(content);
await archive.AddBinary(name, data);
}
public static async Task AddFile(this ZipArchive archive, string name, string path)
{
var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var entry = archive.CreateEntry(name);
await using var dataStream = entry.Open();
await fs.CopyToAsync(dataStream);
await dataStream.FlushAsync();
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Mvc;
using MoonCore.Attributes;
using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Admin.Sys;
using Moonlight.Shared.Http.Responses.Admin.Sys;
using Moonlight.Shared.Misc;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
[ApiController]
[Route("api/admin/system/diagnose")]
[RequirePermission("admin.system.diagnose")]
public class DiagnoseController : Controller
{
private readonly DiagnoseService DiagnoseService;
public DiagnoseController(DiagnoseService diagnoseService)
{
DiagnoseService = diagnoseService;
}
[HttpPost]
public async Task Diagnose([FromBody] GenerateDiagnoseRequest request)
{
var stream = await DiagnoseService.GenerateDiagnose(request.Providers);
await Results.Stream(
stream,
contentType: "application/zip",
fileDownloadName: "diagnose.zip"
)
.ExecuteAsync(HttpContext);
}
[HttpGet("providers")]
public async Task<DiagnoseProvideResponse[]> GetProviders()
{
return await DiagnoseService.GetProviders();
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MoonCore.Attributes; using MoonCore.Attributes;
using Moonlight.ApiServer.Interfaces;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Responses.Admin.Sys; using Moonlight.Shared.Http.Responses.Admin.Sys;
@@ -10,10 +11,13 @@ namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
public class SystemController : Controller public class SystemController : Controller
{ {
private readonly ApplicationService ApplicationService; private readonly ApplicationService ApplicationService;
private readonly IEnumerable<IDiagnoseProvider> DiagnoseProviders;
public SystemController(ApplicationService applicationService)
public SystemController(ApplicationService applicationService, IEnumerable<IDiagnoseProvider> diagnoseProviders)
{ {
ApplicationService = applicationService; ApplicationService = applicationService;
DiagnoseProviders = diagnoseProviders;
} }
[HttpGet] [HttpGet]

View File

@@ -0,0 +1,57 @@
using System.IO.Compression;
using System.Text.Json;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Extensions;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Implementations.Diagnose;
public class CoreConfigDiagnoseProvider : IDiagnoseProvider
{
private readonly AppConfiguration Config;
public CoreConfigDiagnoseProvider(AppConfiguration config)
{
Config = config;
}
private string CheckForNullOrEmpty(string? content)
{
return string.IsNullOrEmpty(content)
? "ISEMPTY"
: "ISNOTEMPTY";
}
public async Task ModifyZipArchive(ZipArchive archive)
{
var json = JsonSerializer.Serialize(Config);
var config = JsonSerializer.Deserialize<AppConfiguration>(json);
if (config == null)
{
await archive.AddText("core/config.txt", "Could not fetch config.");
return;
}
config.Database.Password = CheckForNullOrEmpty(config.Database.Password);
config.Authentication.OAuth2.ClientSecret = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientSecret);
config.Authentication.OAuth2.Secret = CheckForNullOrEmpty(config.Authentication.OAuth2.Secret);
config.Authentication.Secret = CheckForNullOrEmpty(config.Authentication.Secret);
config.Authentication.OAuth2.ClientId = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientId);
await archive.AddText(
"core/config.txt",
JsonSerializer.Serialize(
config,
new JsonSerializerOptions()
{
WriteIndented = true
}
)
);
}
}

View File

@@ -0,0 +1,22 @@
using System.IO.Compression;
using Moonlight.ApiServer.Extensions;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Implementations.Diagnose;
public class LogsDiagnoseProvider : IDiagnoseProvider
{
public async Task ModifyZipArchive(ZipArchive archive)
{
var path = Path.Combine("storage", "logs", "latest.log");
if (!File.Exists(path))
{
await archive.AddText("logs.txt", "Logs file latest.log has not been found");
return;
}
var logsContent = await File.ReadAllTextAsync(path);
await archive.AddText("logs.txt", logsContent);
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database; using Moonlight.ApiServer.Database;
using Moonlight.ApiServer.Implementations.Diagnose;
using Moonlight.ApiServer.Interfaces;
using Moonlight.ApiServer.Plugins; using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Implementations.Startup; namespace Moonlight.ApiServer.Implementations.Startup;
@@ -44,6 +46,13 @@ public class CoreStartup : IPluginStartup
builder.Services.AddDbContext<CoreDataContext>(); builder.Services.AddDbContext<CoreDataContext>();
#endregion
#region Diagnose
builder.Services.AddSingleton<IDiagnoseProvider, CoreConfigDiagnoseProvider>();
builder.Services.AddSingleton<IDiagnoseProvider, LogsDiagnoseProvider>();
#endregion #endregion
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -0,0 +1,8 @@
using System.IO.Compression;
namespace Moonlight.ApiServer.Interfaces;
public interface IDiagnoseProvider
{
public Task ModifyZipArchive(ZipArchive archive);
}

View File

@@ -0,0 +1,95 @@
using Moonlight.ApiServer.Interfaces;
using System.IO.Compression;
using MoonCore.Attributes;
using MoonCore.Exceptions;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Services;
[Scoped]
public class DiagnoseService
{
private readonly IEnumerable<IDiagnoseProvider> DiagnoseProviders;
private readonly ILogger<DiagnoseService> Logger;
public DiagnoseService(
IEnumerable<IDiagnoseProvider> diagnoseProviders,
ILogger<DiagnoseService> logger
)
{
DiagnoseProviders = diagnoseProviders;
Logger = logger;
}
public Task<DiagnoseProvideResponse[]> GetProviders()
{
var availableProviders = new List<DiagnoseProvideResponse>();
foreach (var diagnoseProvider in DiagnoseProviders)
{
var name = diagnoseProvider.GetType().Name;
var type = diagnoseProvider.GetType().FullName;
// The type name is null if the type is a generic type, unlikely, but still could happen
if (type == null)
continue;
availableProviders.Add(new DiagnoseProvideResponse()
{
Name = name,
Type = type
});
}
return Task.FromResult(
availableProviders.ToArray()
);
}
public async Task<MemoryStream> GenerateDiagnose(string[] requestedProviders)
{
IDiagnoseProvider[] providers;
if (requestedProviders.Length == 0)
providers = DiagnoseProviders.ToArray();
else
{
var foundProviders = new List<IDiagnoseProvider>();
foreach (var requestedProvider in requestedProviders)
{
var provider = DiagnoseProviders.FirstOrDefault(x => x.GetType().FullName == requestedProvider);
if (provider == null)
continue;
foundProviders.Add(provider);
}
providers = foundProviders.ToArray();
}
try
{
var outputStream = new MemoryStream();
var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
foreach (var provider in providers)
{
await provider.ModifyZipArchive(zipArchive);
}
zipArchive.Dispose();
outputStream.Position = 0;
return outputStream;
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured while generated the diagnose file: {e}", e);
throw new HttpApiException("An unhandled error occured while generating the diagnose file", 500);
}
}
}

View File

@@ -110,6 +110,7 @@ public class Startup
private Task CreateStorage() private Task CreateStorage()
{ {
Directory.CreateDirectory("storage"); Directory.CreateDirectory("storage");
Directory.CreateDirectory(PathBuilder.Dir("storage", "logs"));
Directory.CreateDirectory(PathBuilder.Dir("storage", "plugins")); Directory.CreateDirectory(PathBuilder.Dir("storage", "plugins"));
return Task.CompletedTask; return Task.CompletedTask;
@@ -338,7 +339,10 @@ public class Startup
{ {
configuration.Console.Enable = true; configuration.Console.Enable = true;
configuration.Console.EnableAnsiMode = true; configuration.Console.EnableAnsiMode = true;
configuration.FileLogging.Enable = false; configuration.FileLogging.Enable = true;
configuration.FileLogging.Path = PathBuilder.File("storage", "logs", "latest.log");
configuration.FileLogging.EnableLogRotation = true;
configuration.FileLogging.RotateLogNameTemplate = PathBuilder.File("storage", "logs", "apiserver.{0}.log");
}); });
LoggerFactory = new LoggerFactory(); LoggerFactory = new LoggerFactory();

View File

@@ -0,0 +1,120 @@
@page "/admin/system/diagnose"
@using MoonCore.Attributes
@using MoonCore.Helpers
@using Moonlight.Shared.Http.Requests.Admin.Sys
@using Moonlight.Shared.Http.Responses.Admin.Sys
@attribute [RequirePermission("admin.system.diagnose")]
@inject HttpApiClient ApiClient
@inject DownloadService DownloadService
<div class="mb-5">
<NavTabs Index="5" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/>
</div>
<div class="grid grid-cols-2">
<div class="col-span-2 md:col-span-1 card">
<div class="card-header">
<span class="card-title">Diagnose</span>
</div>
<div class="card-body">
<p>
If you're experiencing issues or need help via our Discord, you're in the right place here!
By pressing the button below, Moonlight will run all available diagnostic checks and package the results
into a
downloadable zip file.
The report includes useful information about your system, plugins, and environment, making it easier to
identify problems or share with support.
</p>
<WButton OnClick="GenerateDiagnose" CssClasses="btn btn-primary my-5">Generate diagnose</WButton>
<div class="text-sm">
<a class="text-primary cursor-pointer flex items-center" @onclick:preventDefault
@onclick="ToggleDropDown">
<span class="me-1.5">Advanced</span>
@if (DropdownOpen)
{
<i class="icon-chevron-up"></i>
}
else
{
<i class="icon-chevron-down"></i>
}
</a>
<div class="@(DropdownOpen ? "" : "hidden")">
<LazyLoader Load="Load">
<div class="mb-2 py-2 border-b border-gray-700 flex items-center gap-3">
<input id="selectall_checkbox" @bind="SelectAll" type="checkbox" class="form-checkbox">
<label for="selectall_checkbox">Select all</label>
</div>
@foreach (var item in AvailableProviders)
{
<div class="mt-1 flex gap-3 items-center">
<input class="form-checkbox" type="checkbox" id="@(item.Key.Type + "_checkbox")"
@bind="@AvailableProviders[item.Key]"/>
<label
for="@(item.Key.Type + "_checkbox")">@Formatter.ConvertCamelCaseToSpaces(item.Key.Name)</label>
</div>
}
</LazyLoader>
</div>
</div>
</div>
</div>
</div>
@code
{
private bool DropdownOpen = false;
private Dictionary<DiagnoseProvideResponse, bool> AvailableProviders;
private bool SelectAll
{
get => AvailableProviders.Values.All(v => v);
set
{
foreach (var k in AvailableProviders.Keys)
AvailableProviders[k] = value;
}
}
private async Task Load(LazyLoader arg)
{
var providers = await ApiClient.GetJson<DiagnoseProvideResponse[]>(
"api/admin/system/diagnose/providers"
);
AvailableProviders = providers
.ToDictionary(x => x, _ => true);
}
private async Task GenerateDiagnose(WButton _)
{
var request = new GenerateDiagnoseRequest();
if (!SelectAll)
{
// filter the providers which have been selected if not all providers have been selected
request.Providers = AvailableProviders
.Where(x => x.Value)
.Select(x => x.Key.Type)
.ToArray();
}
var stream = await ApiClient.PostStream("api/admin/system/diagnose", request);
await DownloadService.DownloadStream("diagnose.zip", stream);
}
private async Task ToggleDropDown()
{
DropdownOpen = !DropdownOpen;
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -4,12 +4,12 @@ public static class UiConstants
{ {
public static readonly string[] AdminNavNames = public static readonly string[] AdminNavNames =
[ [
"Overview", "Theme", "Files", "Hangfire", "Advanced" "Overview", "Theme", "Files", "Hangfire", "Advanced", "Diagnose"
]; ];
public static readonly string[] AdminNavLinks = public static readonly string[] AdminNavLinks =
[ [
"/admin/system", "/admin/system/theme", "/admin/system/files", "/admin/system/hangfire", "/admin/system", "/admin/system/theme", "/admin/system/files", "/admin/system/hangfire",
"/admin/system/advanced" "/admin/system/advanced", "/admin/system/diagnose"
]; ];
} }

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Shared.Http.Requests.Admin.Sys;
public class GenerateDiagnoseRequest
{
public string[] Providers { get; set; } = [];
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Shared.Http.Responses.Admin.Sys;
public class DiagnoseProvideResponse
{
public string Name { get; set; }
public string Type { get; set; }
}