diff --git a/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs b/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs new file mode 100644 index 00000000..95c928d7 --- /dev/null +++ b/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs @@ -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(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs new file mode 100644 index 00000000..94ae9d51 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs @@ -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 GetProviders() + { + return await DiagnoseService.GetProviders(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs index b0c9f8d2..e69efc8f 100644 --- a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using MoonCore.Attributes; +using Moonlight.ApiServer.Interfaces; using Moonlight.ApiServer.Services; using Moonlight.Shared.Http.Responses.Admin.Sys; @@ -10,10 +11,13 @@ namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; public class SystemController : Controller { private readonly ApplicationService ApplicationService; + private readonly IEnumerable DiagnoseProviders; - public SystemController(ApplicationService applicationService) + + public SystemController(ApplicationService applicationService, IEnumerable diagnoseProviders) { ApplicationService = applicationService; + DiagnoseProviders = diagnoseProviders; } [HttpGet] diff --git a/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs b/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs new file mode 100644 index 00000000..689148aa --- /dev/null +++ b/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs @@ -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(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 + } + ) + ); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs b/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs new file mode 100644 index 00000000..0ef1e82b --- /dev/null +++ b/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs @@ -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); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs index b5293345..3f27ab65 100644 --- a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs +++ b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs @@ -1,6 +1,8 @@ using Microsoft.OpenApi.Models; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database; +using Moonlight.ApiServer.Implementations.Diagnose; +using Moonlight.ApiServer.Interfaces; using Moonlight.ApiServer.Plugins; namespace Moonlight.ApiServer.Implementations.Startup; @@ -44,6 +46,13 @@ public class CoreStartup : IPluginStartup builder.Services.AddDbContext(); + #endregion + + #region Diagnose + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + #endregion return Task.CompletedTask; diff --git a/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs b/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs new file mode 100644 index 00000000..5a4a9cf2 --- /dev/null +++ b/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs @@ -0,0 +1,8 @@ +using System.IO.Compression; + +namespace Moonlight.ApiServer.Interfaces; + +public interface IDiagnoseProvider +{ + public Task ModifyZipArchive(ZipArchive archive); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Services/DiagnoseService.cs b/Moonlight.ApiServer/Services/DiagnoseService.cs new file mode 100644 index 00000000..a5844b04 --- /dev/null +++ b/Moonlight.ApiServer/Services/DiagnoseService.cs @@ -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 DiagnoseProviders; + private readonly ILogger Logger; + + public DiagnoseService( + IEnumerable diagnoseProviders, + ILogger logger + ) + { + DiagnoseProviders = diagnoseProviders; + Logger = logger; + } + + public Task GetProviders() + { + var availableProviders = new List(); + + 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 GenerateDiagnose(string[] requestedProviders) + { + IDiagnoseProvider[] providers; + + if (requestedProviders.Length == 0) + providers = DiagnoseProviders.ToArray(); + else + { + var foundProviders = new List(); + + 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); + } + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Startup.cs b/Moonlight.ApiServer/Startup.cs index 84695cc1..f9f4a00a 100644 --- a/Moonlight.ApiServer/Startup.cs +++ b/Moonlight.ApiServer/Startup.cs @@ -110,6 +110,7 @@ public class Startup private Task CreateStorage() { Directory.CreateDirectory("storage"); + Directory.CreateDirectory(PathBuilder.Dir("storage", "logs")); Directory.CreateDirectory(PathBuilder.Dir("storage", "plugins")); return Task.CompletedTask; @@ -338,7 +339,10 @@ public class Startup { configuration.Console.Enable = 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(); diff --git a/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor b/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor new file mode 100644 index 00000000..0a544f41 --- /dev/null +++ b/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor @@ -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 + +
+ +
+ +
+
+
+ Diagnose +
+
+

+ 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. +

+ + Generate diagnose + +
+ + Advanced + @if (DropdownOpen) + { + + } + else + { + + } + + +
+ +
+ + +
+ + @foreach (var item in AvailableProviders) + { +
+ + +
+ } +
+
+
+
+
+
+ +@code +{ + private bool DropdownOpen = false; + private Dictionary 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( + "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); + } +} \ No newline at end of file diff --git a/Moonlight.Client/UiConstants.cs b/Moonlight.Client/UiConstants.cs index 9293cc7f..dce82611 100644 --- a/Moonlight.Client/UiConstants.cs +++ b/Moonlight.Client/UiConstants.cs @@ -4,12 +4,12 @@ public static class UiConstants { public static readonly string[] AdminNavNames = [ - "Overview", "Theme", "Files", "Hangfire", "Advanced" + "Overview", "Theme", "Files", "Hangfire", "Advanced", "Diagnose" ]; public static readonly string[] AdminNavLinks = [ "/admin/system", "/admin/system/theme", "/admin/system/files", "/admin/system/hangfire", - "/admin/system/advanced" + "/admin/system/advanced", "/admin/system/diagnose" ]; } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Admin/Sys/GenerateDiagnoseRequest.cs b/Moonlight.Shared/Http/Requests/Admin/Sys/GenerateDiagnoseRequest.cs new file mode 100644 index 00000000..3757ab8e --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Admin/Sys/GenerateDiagnoseRequest.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Shared.Http.Requests.Admin.Sys; + +public class GenerateDiagnoseRequest +{ + public string[] Providers { get; set; } = []; +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Admin/Sys/DiagnoseProvideResponse.cs b/Moonlight.Shared/Http/Responses/Admin/Sys/DiagnoseProvideResponse.cs new file mode 100644 index 00000000..6a23a991 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Admin/Sys/DiagnoseProvideResponse.cs @@ -0,0 +1,7 @@ +namespace Moonlight.Shared.Http.Responses.Admin.Sys; + +public class DiagnoseProvideResponse +{ + public string Name { get; set; } + public string Type { get; set; } +} \ No newline at end of file