diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs index f1dbfb7d14..ed3f5e30e2 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Components.Routing; using Boilerplate.Shared.Controllers.Identity; using Boilerplate.Client.Core.Services.DiagnosticLog; +using Boilerplate.Shared.Dtos.Diagnostic; namespace Boilerplate.Client.Core.Components; @@ -220,9 +221,24 @@ private void SubscribeToSignalREventsMessages() ExceptionHandler.Handle(appProblemDetails, displayKind: ExceptionDisplayKind.NonInterrupting); })); - signalROnDisposables.Add(hubConnection.On(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, async () => + signalROnDisposables.Add(hubConnection.On(SignalRMethods.REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH, async correlationId => { - return DiagnosticLogger.Store.ToArray(); + async IAsyncEnumerable EnumerateLogs(CancellationToken ct) + { + var logs = DiagnosticLogger.Store.ToArray(); + foreach (var log in logs) + { + if (ct.IsCancellationRequested) yield break; + yield return log; + await Task.Yield(); // keep UI responsive + } + } + + await hubConnection.SendAsync( + SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STREAM, + correlationId, + EnumerateLogs(CurrentCancellationToken), + CurrentCancellationToken); })); hubConnection.Closed += HubConnectionStateChange; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs index ba21a6aae7..880b2b83d5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs @@ -191,12 +191,48 @@ private void SearchSessions() /// private async Task ReadUserSessionLogs(Guid userSessionId) { - var logs = await hubConnection.InvokeAsync("GetUserSessionLogs", userSessionId); - DiagnosticLogger.Store.Clear(); - foreach (var log in logs) + + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Receive per-log events + var d1 = hubConnection.On(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, log => { DiagnosticLogger.Store.Enqueue(log); + }); + + // Lifecycle events + var d2 = hubConnection.On(SignalRMethods.DIAGNOSTIC_LOGS_COMPLETE, _ => completed.TrySetResult()); + var d3 = hubConnection.On(SignalRMethods.DIAGNOSTIC_LOGS_ABORTED, _ => completed.TrySetResult()); + var d4 = hubConnection.On(SignalRMethods.DIAGNOSTIC_LOGS_ERROR, _ => completed.TrySetResult()); + + // Join temp group and trigger the target to stream + var correlationId = await hubConnection.InvokeAsync( + "BeginUserSessionLogs", + userSessionId, + cancellationToken: CurrentCancellationToken); + if (string.IsNullOrEmpty(correlationId)) + { + d1.Dispose(); d2.Dispose(); d3.Dispose(); d4.Dispose(); + return; + } + + try + { + // Optional safety timeout for first item or completion + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(CurrentCancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(15)); + + await completed.Task.WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + // ignore, just end gracefully + } + finally + { + d1.Dispose(); d2.Dispose(); d3.Dispose(); d4.Dispose(); + await hubConnection.InvokeAsync("EndUserSessionLogs", correlationId); } PubSubService.Publish(ClientPubSubMessages.SHOW_DIAGNOSTIC_MODAL); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs new file mode 100644 index 0000000000..8314cd427b --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs @@ -0,0 +1,71 @@ +//+:cnd:noEmit +using Boilerplate.Shared.Dtos.Diagnostic; +using Microsoft.AspNetCore.SignalR; + +namespace Boilerplate.Server.Api.SignalR; + +public partial class AppHub +{ + /// + /// Adds the admin caller to a temp group and requests the target client to upload logs. + /// Returns a correlationId (group name) or null if target not connected. + /// + [Authorize(Policy = AppFeatures.System.ManageLogs)] + public async Task BeginUserSessionLogs( + Guid userSessionId, + [FromServices] AppDbContext dbContext) + { + var targetConnId = await dbContext.UserSessions + .Where(us => us.Id == userSessionId) + .Select(us => us.SignalRConnectionId) + .FirstOrDefaultAsync(); + + if (string.IsNullOrEmpty(targetConnId)) + return null; + + var correlationId = Guid.NewGuid().ToString("N"); + + await Groups.AddToGroupAsync(Context.ConnectionId, correlationId, Context.ConnectionAborted); + + await Clients.Client(targetConnId) + .SendAsync(SignalRMethods.REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH, correlationId, Context.ConnectionAborted); + + return correlationId; + } + + [Authorize(Policy = AppFeatures.System.ManageLogs)] + public Task EndUserSessionLogs(string correlationId) + => Groups.RemoveFromGroupAsync(Context.ConnectionId, correlationId); + + /// + /// Target client -> Hub: streams its logs once; hub forwards each item to the admin correlation group. + /// Handles completion, cancellation and errors. + /// + [HubMethodName(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STREAM)] + public async Task UploadDiagnosticLoggerStream( + string correlationId, + IAsyncEnumerable logs) + { + try + { + await foreach (var log in logs) + { + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, log); + } + + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.DIAGNOSTIC_LOGS_COMPLETE, correlationId); + } + catch (OperationCanceledException) + { + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.DIAGNOSTIC_LOGS_ABORTED, correlationId); + } + catch + { + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.DIAGNOSTIC_LOGS_ERROR, correlationId); + } + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs index dbfa6ab699..cbf3081a9c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs @@ -62,23 +62,6 @@ public Task ChangeAuthenticationState(string? accessToken) return ChangeAuthenticationStateImplementation(user); } - /// - /// - /// - [Authorize(Policy = AppFeatures.System.ManageLogs)] - public async Task GetUserSessionLogs(Guid userSessionId, [FromServices] AppDbContext dbContext) - { - var userSessionSignalRConnectionId = await dbContext.UserSessions - .Where(us => us.Id == userSessionId) - .Select(us => us.SignalRConnectionId) - .FirstOrDefaultAsync(Context.ConnectionAborted); - - if (string.IsNullOrEmpty(userSessionSignalRConnectionId)) - return []; - - return await Clients.Client(userSessionSignalRConnectionId).InvokeAsync(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, Context.ConnectionAborted); - } - private async Task ChangeAuthenticationStateImplementation(ClaimsPrincipal? user) { await using var scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs index 59a9518ee7..442f8e0d03 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs @@ -44,5 +44,21 @@ public static partial class SignalRMethods /// Uploading these logs for display in the support staff's diagnostic modal log viewer aids in pinpointing the root cause of user issues during live troubleshooting. /// Another benefit of having this feature is in dev environment when you wanna see your Android, iOS logs on your desktop wide screen. /// + public const string REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH = nameof(REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH); + + /// + /// Client > Server: uploads a batch of diagnostic logs (use with a small, safe batch size). + /// + public const string UPLOAD_DIAGNOSTIC_LOGGER_BATCH = nameof(UPLOAD_DIAGNOSTIC_LOGGER_BATCH); + + // Target client -> Hub: stream logs + public const string UPLOAD_DIAGNOSTIC_LOGGER_STREAM = nameof(UPLOAD_DIAGNOSTIC_LOGGER_STREAM); + + // Hub -> Admin: deliver each log item public const string UPLOAD_DIAGNOSTIC_LOGGER_STORE = nameof(UPLOAD_DIAGNOSTIC_LOGGER_STORE); + + // Hub -> Admin: lifecycle events + public const string DIAGNOSTIC_LOGS_COMPLETE = nameof(DIAGNOSTIC_LOGS_COMPLETE); + public const string DIAGNOSTIC_LOGS_ABORTED = nameof(DIAGNOSTIC_LOGS_ABORTED); + public const string DIAGNOSTIC_LOGS_ERROR = nameof(DIAGNOSTIC_LOGS_ERROR); }