Merge pull request #456

Implemented SignalR (+ Scaling)
This commit is contained in:
2025-09-16 10:04:20 +02:00
committed by GitHub
15 changed files with 193 additions and 70 deletions

View File

@@ -30,6 +30,9 @@ public record AppConfiguration
[YamlMember(Description = "\nSettings for open telemetry")] [YamlMember(Description = "\nSettings for open telemetry")]
public OpenTelemetryData OpenTelemetry { get; set; } = new(); public OpenTelemetryData OpenTelemetry { get; set; } = new();
[YamlMember(Description = "\nConfiguration for the realtime communication solution SignalR")]
public SignalRData SignalR { get; set; } = new();
public static AppConfiguration CreateEmpty() public static AppConfiguration CreateEmpty()
{ {
return new AppConfiguration() return new AppConfiguration()
@@ -47,6 +50,15 @@ public record AppConfiguration
}; };
} }
public record SignalRData
{
[YamlMember(Description =
"\nWhether to use redis (or any other redis compatible solution) to scale out SignalR hubs. This is required when using multiple api server replicas")]
public bool UseRedis { get; set; } = false;
public string RedisConnectionString { get; set; } = "";
}
public record FilesData public record FilesData
{ {
[YamlMember(Description = "The maximum file size limit a combine operation is allowed to process")] [YamlMember(Description = "The maximum file size limit a combine operation is allowed to process")]

View File

@@ -5,7 +5,7 @@ namespace Moonlight.ApiServer.Extensions;
public static class ZipArchiveExtensions public static class ZipArchiveExtensions
{ {
public static async Task AddBinary(this ZipArchive archive, string name, byte[] bytes) public static async Task AddBinaryAsync(this ZipArchive archive, string name, byte[] bytes)
{ {
var entry = archive.CreateEntry(name); var entry = archive.CreateEntry(name);
await using var dataStream = entry.Open(); await using var dataStream = entry.Open();
@@ -14,13 +14,13 @@ public static class ZipArchiveExtensions
await dataStream.FlushAsync(); await dataStream.FlushAsync();
} }
public static async Task AddText(this ZipArchive archive, string name, string content) public static async Task AddTextAsync(this ZipArchive archive, string name, string content)
{ {
var data = Encoding.UTF8.GetBytes(content); var data = Encoding.UTF8.GetBytes(content);
await archive.AddBinary(name, data); await archive.AddBinaryAsync(name, data);
} }
public static async Task AddFile(this ZipArchive archive, string name, string path) public static async Task AddFileAsync(this ZipArchive archive, string name, string path)
{ {
var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

View File

@@ -21,21 +21,16 @@ public class DiagnoseController : Controller
} }
[HttpPost] [HttpPost]
public async Task Diagnose([FromBody] GenerateDiagnoseRequest request) public async Task<ActionResult> Diagnose([FromBody] GenerateDiagnoseRequest request)
{ {
var stream = await DiagnoseService.GenerateDiagnose(request.Providers); var stream = await DiagnoseService.GenerateDiagnoseAsync(request.Providers);
await Results.Stream( return File(stream, "application/zip", "diagnose.zip");
stream,
contentType: "application/zip",
fileDownloadName: "diagnose.zip"
)
.ExecuteAsync(HttpContext);
} }
[HttpGet("providers")] [HttpGet("providers")]
public async Task<DiagnoseProvideResponse[]> GetProviders() public async Task<ActionResult<DiagnoseProvideResponse[]>> GetProviders()
{ {
return await DiagnoseService.GetProviders(); return await DiagnoseService.GetProvidersAsync();
} }
} }

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Moonlight.ApiServer.Http.Hubs;
[Authorize(Policy = "permissions:admin.system.diagnose")]
public class DiagnoseHub : Hub
{
[HubMethodName("Ping")]
public async Task Ping()
{
await Clients.All.SendAsync("Pong");
}
}

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Compression; using System.IO.Compression;
using System.Text.Json; using MoonCore.Yaml;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Extensions; using Moonlight.ApiServer.Extensions;
using Moonlight.ApiServer.Interfaces; using Moonlight.ApiServer.Interfaces;
@@ -8,11 +9,11 @@ namespace Moonlight.ApiServer.Implementations.Diagnose;
public class CoreConfigDiagnoseProvider : IDiagnoseProvider public class CoreConfigDiagnoseProvider : IDiagnoseProvider
{ {
private readonly AppConfiguration Config; private readonly AppConfiguration Configuration;
public CoreConfigDiagnoseProvider(AppConfiguration config) public CoreConfigDiagnoseProvider(AppConfiguration configuration)
{ {
Config = config; Configuration = configuration;
} }
private string CheckForNullOrEmpty(string? content) private string CheckForNullOrEmpty(string? content)
@@ -22,29 +23,25 @@ public class CoreConfigDiagnoseProvider : IDiagnoseProvider
: "ISNOTEMPTY"; : "ISNOTEMPTY";
} }
public async Task ModifyZipArchive(ZipArchive archive) public async Task ModifyZipArchiveAsync(ZipArchive archive)
{ {
var json = JsonSerializer.Serialize(Config); try
var config = JsonSerializer.Deserialize<AppConfiguration>(json);
if (config == null)
{ {
await archive.AddText("core/config.txt", "Could not fetch config."); var configString = YamlSerializer.Serialize(Configuration);
return; var configuration = YamlSerializer.Deserialize<AppConfiguration>(configString);
}
config.Database.Password = CheckForNullOrEmpty(config.Database.Password); configuration.Database.Password = CheckForNullOrEmpty(configuration.Database.Password);
config.Authentication.Secret = CheckForNullOrEmpty(config.Authentication.Secret); configuration.Authentication.Secret = CheckForNullOrEmpty(configuration.Authentication.Secret);
configuration.SignalR.RedisConnectionString = CheckForNullOrEmpty(configuration.SignalR.RedisConnectionString);
await archive.AddText( await archive.AddTextAsync(
"core/config.txt", "core/config.txt",
JsonSerializer.Serialize( YamlSerializer.Serialize(configuration)
config,
new JsonSerializerOptions()
{
WriteIndented = true
}
)
); );
} }
catch (Exception e)
{
await archive.AddTextAsync("core/config.txt", $"Unable to load config: {e.ToStringDemystified()}");
}
}
} }

View File

@@ -6,17 +6,16 @@ namespace Moonlight.ApiServer.Implementations.Diagnose;
public class LogsDiagnoseProvider : IDiagnoseProvider public class LogsDiagnoseProvider : IDiagnoseProvider
{ {
public async Task ModifyZipArchive(ZipArchive archive) public async Task ModifyZipArchiveAsync(ZipArchive archive)
{ {
var path = Path.Combine("storage", "logs", "latest.log"); var path = Path.Combine("storage", "logs", "moonlight.log");
if (!File.Exists(path)) if (File.Exists(path))
{ {
await archive.AddText("logs.txt", "Logs file latest.log has not been found");
return;
}
var logsContent = await File.ReadAllTextAsync(path); var logsContent = await File.ReadAllTextAsync(path);
await archive.AddText("logs.txt", logsContent); await archive.AddTextAsync("logs.txt", logsContent);
}
else
await archive.AddTextAsync("logs.txt", "Logs file moonlight.log has not been found");
} }
} }

View File

@@ -4,5 +4,5 @@ namespace Moonlight.ApiServer.Interfaces;
public interface IDiagnoseProvider public interface IDiagnoseProvider
{ {
public Task ModifyZipArchive(ZipArchive archive); public Task ModifyZipArchiveAsync(ZipArchive archive);
} }

View File

@@ -25,6 +25,7 @@
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20"/> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.20"/>
<PackageReference Include="Hangfire.Core" Version="1.8.20"/> <PackageReference Include="Hangfire.Core" Version="1.8.20"/>
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/> <PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.9" />
<PackageReference Include="MoonCore" Version="1.9.7" /> <PackageReference Include="MoonCore" Version="1.9.7" />
<PackageReference Include="MoonCore.Extended" Version="1.3.7" /> <PackageReference Include="MoonCore.Extended" Version="1.3.7" />
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/> <PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>

View File

@@ -22,7 +22,7 @@ public class DiagnoseService
Logger = logger; Logger = logger;
} }
public Task<DiagnoseProvideResponse[]> GetProviders() public Task<DiagnoseProvideResponse[]> GetProvidersAsync()
{ {
var availableProviders = new List<DiagnoseProvideResponse>(); var availableProviders = new List<DiagnoseProvideResponse>();
@@ -48,7 +48,7 @@ public class DiagnoseService
); );
} }
public async Task<MemoryStream> GenerateDiagnose(string[] requestedProviders) public async Task<MemoryStream> GenerateDiagnoseAsync(string[] requestedProviders)
{ {
IDiagnoseProvider[] providers; IDiagnoseProvider[] providers;
@@ -78,7 +78,7 @@ public class DiagnoseService
foreach (var provider in providers) foreach (var provider in providers)
{ {
await provider.ModifyZipArchive(zipArchive); await provider.ModifyZipArchiveAsync(zipArchive);
} }
zipArchive.Dispose(); zipArchive.Dispose();

View File

@@ -23,15 +23,18 @@ public partial class Startup
// we want to use the ApiKey scheme for authenticating the request // we want to use the ApiKey scheme for authenticating the request
options.ForwardDefaultSelector = context => options.ForwardDefaultSelector = context =>
{ {
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader)) var headers = context.Request.Headers;
return "Session";
var auth = authHeader.FirstOrDefault();
if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer "))
return "Session";
// For regular api calls
if (headers.ContainsKey("Authorization"))
return "ApiKey"; return "ApiKey";
// For websocket requests which cannot use the Authorization header
if (headers.Upgrade == "websocket" && headers.Connection == "Upgrade" && context.Request.Query.ContainsKey("access_token"))
return "ApiKey";
// Regular user traffic/auth
return "Session";
}; };
}) })
.AddJwtBearer("ApiKey", null, options => .AddJwtBearer("ApiKey", null, options =>
@@ -63,6 +66,16 @@ public partial class Startup
if (!result) if (!result)
context.Fail("API key has been deleted"); context.Fail("API key has been deleted");
},
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken))
context.Token = accessToken;
return Task.CompletedTask;
} }
}; };
}) })

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.ApiServer.Http.Hubs;
namespace Moonlight.ApiServer.Startup;
public partial class Startup
{
public Task RegisterSignalR()
{
var signalRBuilder = WebApplicationBuilder.Services.AddSignalR();
if (Configuration.SignalR.UseRedis)
signalRBuilder.AddStackExchangeRedis(Configuration.SignalR.RedisConnectionString);
return Task.CompletedTask;
}
public Task MapSignalR()
{
WebApplication.MapHub<DiagnoseHub>("/api/admin/system/diagnose/ws");
return Task.CompletedTask;
}
}

View File

@@ -46,6 +46,7 @@ public partial class Startup
await RegisterAuth(); await RegisterAuth();
await RegisterCors(); await RegisterCors();
await RegisterHangfire(); await RegisterHangfire();
await RegisterSignalR();
await HookPluginBuild(); await HookPluginBuild();
} }
@@ -62,6 +63,7 @@ public partial class Startup
await HookPluginConfigure(); await HookPluginConfigure();
await MapBase(); await MapBase();
await MapSignalR();
await HookPluginEndpoints(); await HookPluginEndpoints();
} }
} }

View File

@@ -22,6 +22,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazor-ApexCharts" Version="6.0.2" /> <PackageReference Include="Blazor-ApexCharts" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
<PackageReference Include="MoonCore" Version="1.9.7" /> <PackageReference Include="MoonCore" Version="1.9.7" />
<PackageReference Include="MoonCore.Blazor" Version="1.3.1" /> <PackageReference Include="MoonCore.Blazor" Version="1.3.1" />

View File

@@ -0,0 +1,62 @@
@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.Extensions.DependencyInjection
@inject NavigationManager Navigation
@inject ToastService ToastService
@implements IAsyncDisposable
<div class="card">
<div class="card-header">
<h3 class="card-title">SignalR</h3>
</div>
<div class="card-body">
<p>
<a class="link" href="https://dotnet.microsoft.com/en-us/apps/aspnet/signalr">SignalR</a> is used by Moonlight to provide realtime communication to itself and other modules.
You can test the SignalR communication by pressing the button below. For scaled instances you need to configure a redis compatible server to be used by all your replicas in order
for all SignalR Hubs to be synced.
</p>
<div class="mt-5">
<LazyLoader Load="Load">
<WButton OnClick="OnClick" CssClasses="btn btn-primary">
Send broadcast
</WButton>
</LazyLoader>
</div>
</div>
</div>
@code
{
private HubConnection? Connection;
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.UpdateText("Connecting to SignalR endpoint");
Connection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/api/admin/system/diagnose/ws"))
.AddJsonProtocol()
.Build();
Connection.On(
"Pong",
async () => await ToastService.Success("Received broadcast")
);
await Connection.StartAsync();
}
private async Task OnClick(WButton _)
{
if (Connection == null)
return;
await Connection.SendAsync("Ping");
}
public async ValueTask DisposeAsync()
{
if (Connection != null) await Connection.DisposeAsync();
}
}

View File

@@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using MoonCore.Blazor.FlyonUi.Helpers @using MoonCore.Blazor.FlyonUi.Helpers
@using MoonCore.Helpers @using MoonCore.Helpers
@using Moonlight.Client.UI.Components
@using Moonlight.Shared.Http.Requests.Admin.Sys @using Moonlight.Shared.Http.Requests.Admin.Sys
@using Moonlight.Shared.Http.Responses.Admin.Sys @using Moonlight.Shared.Http.Responses.Admin.Sys
@@ -15,22 +16,19 @@
<NavTabs Index="5" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/> <NavTabs Index="5" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/>
</div> </div>
<div class="grid grid-cols-2"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div class="col-span-2 md:col-span-1 card"> <div class="col-span-1 card">
<div class="card-header"> <div class="card-header">
<span class="card-title">Diagnose</span> <span class="card-title">Report</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<p> <p>
If you're experiencing issues or need help via our Discord, you're in the right place here! With the button below you can create a diagnose report containing all important information to troubleshoot your moonlight instance and its modules.
By pressing the button below, Moonlight will run all available diagnostic checks and package the results The diagnose file is a zip containing different logs and censored config files which can be shared with our support on discord.
into a If you only want to export specific parts of the diagnose report, click on "Advanced" and select the desired providers
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> </p>
<WButton OnClick="GenerateDiagnose" CssClasses="btn btn-primary my-5">Generate diagnose</WButton> <WButton OnClick="GenerateDiagnose" CssClasses="btn btn-primary my-5">Generate report</WButton>
<div class="text-sm"> <div class="text-sm">
<a class="text-primary cursor-pointer flex items-center" @onclick:preventDefault <a class="text-primary cursor-pointer flex items-center" @onclick:preventDefault
@@ -67,6 +65,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-span-1">
<SignalRDebug />
</div>
</div> </div>
@code @code