From b850997f628ba4d2d2ae58b9359c1dd6a29ef236 Mon Sep 17 00:00:00 2001 From: Tudor Stanciu Date: Sun, 1 Jun 2025 18:38:38 +0000 Subject: [PATCH] Merged PR 96: Implement SignalR integration for real-time notifications and messaging Implement SignalR integration for real-time notifications and messaging --- backend/Directory.Build.props | 2 +- backend/ReleaseNotes.xml | 16 +- .../CommandHandlers/WakeMachineHandler.cs | 17 +- .../Extensions/EntityExtensions.cs | 2 +- .../Abstractions/IMessageHubPublisher.cs | 13 + .../Abstractions/INotificationService.cs | 4 +- .../Services/NotificationService.cs | 7 +- .../Notification.cs | 2 +- .../NotificationContext.cs | 2 +- .../NotificationResult.cs | 15 + .../NotificationTemplate.cs | 2 +- .../Models/InternalNotifications/EmailSent.cs | 9 + .../RealtimeNotification.cs | 22 ++ .../RealtimeNotificationsController.cs | 49 +++ .../Extensions/HttpRequestExtensions.cs | 24 ++ .../Extensions/SignalRExtensions.cs | 73 ++++ .../Extensions/StartupExtensions.cs | 20 +- .../NetworkResurrector.Api/Hubs/MessageHub.cs | 96 +++++ .../NetworkResurrector.Api.csproj | 1 + .../src/api/NetworkResurrector.Api/Program.cs | 2 +- .../Services/MessageHubPublisher.cs | 82 ++++ .../NetworkResurrector.Api/appsettings.json | 3 + frontend/.env | 2 +- frontend/.env.production | 2 +- frontend/.prettierrc | 2 +- frontend/package-lock.json | 167 +++++++- frontend/package.json | 4 +- frontend/public/locales/en/translations.json | 6 +- frontend/public/locales/ro/translations.json | 6 +- frontend/src/components/common/Hint.tsx | 21 + frontend/src/components/common/index.js | 3 +- frontend/src/components/icons/list.ts | 16 +- frontend/src/components/layout/AppRoutes.tsx | 2 + frontend/src/components/layout/SideBar.tsx | 4 +- frontend/src/components/layout/TopBar.tsx | 2 + .../src/components/layout/constants/index.ts | 3 +- .../src/components/layout/constants/menu.tsx | 48 ++- .../layout/notifications/AppNotifications.tsx | 114 ++++++ .../layout/notifications/PopoverContent.tsx | 173 +++++++++ .../components/layout/notifications/types.ts | 29 ++ frontend/src/constants/index.ts | 4 +- frontend/src/constants/notificationTypes.ts | 9 + .../notifications/NotificationApiTest.tsx | 153 ++++++++ .../notifications/NotificationDemo.tsx | 358 ++++++++++++++++++ .../notifications/parts/ConnectionStatus.tsx | 36 ++ .../debugging/notifications/parts/index.ts | 3 + frontend/src/hooks/{index.js => index.ts} | 2 + frontend/src/hooks/useSensitiveInfo.js | 2 +- frontend/src/index.tsx | 6 +- frontend/src/providers/index.js | 3 +- .../RealtimeNotificationsContext.ts | 41 ++ .../RealtimeNotificationsProvider.tsx | 203 ++++++++++ .../components/ConnectingBox.tsx | 30 ++ .../notifications/components/ErrorBox.tsx | 33 ++ frontend/src/units/notifications/constants.ts | 5 + .../src/units/notifications/hooks/index.ts | 4 + .../hooks/useRealtimeNotifications.ts | 12 + .../notifications/hooks/useSubscription.ts | 34 ++ frontend/src/units/notifications/index.ts | 3 + .../src/units/notifications/signalRService.ts | 141 +++++++ frontend/src/units/notifications/types.ts | 20 + frontend/src/units/swr/fetchers.ts | 6 + frontend/src/utils/api.ts | 2 +- frontend/src/utils/obfuscateStrings.js | 2 +- frontend/src/utils/uid.ts | 8 + 65 files changed, 2132 insertions(+), 55 deletions(-) create mode 100644 backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/IMessageHubPublisher.cs rename backend/src/api/NetworkResurrector.Api.Domain/Models/{Notifications => ExternalNotifications}/Notification.cs (70%) rename backend/src/api/NetworkResurrector.Api.Domain/Models/{Notifications => ExternalNotifications}/NotificationContext.cs (82%) create mode 100644 backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationResult.cs rename backend/src/api/NetworkResurrector.Api.Domain/Models/{Notifications => ExternalNotifications}/NotificationTemplate.cs (71%) create mode 100644 backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/EmailSent.cs create mode 100644 backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/RealtimeNotification.cs create mode 100644 backend/src/api/NetworkResurrector.Api/Controllers/RealtimeNotificationsController.cs create mode 100644 backend/src/api/NetworkResurrector.Api/Extensions/HttpRequestExtensions.cs create mode 100644 backend/src/api/NetworkResurrector.Api/Extensions/SignalRExtensions.cs create mode 100644 backend/src/api/NetworkResurrector.Api/Hubs/MessageHub.cs create mode 100644 backend/src/api/NetworkResurrector.Api/Services/MessageHubPublisher.cs create mode 100644 frontend/src/components/common/Hint.tsx create mode 100644 frontend/src/components/layout/notifications/AppNotifications.tsx create mode 100644 frontend/src/components/layout/notifications/PopoverContent.tsx create mode 100644 frontend/src/components/layout/notifications/types.ts create mode 100644 frontend/src/constants/notificationTypes.ts create mode 100644 frontend/src/features/debugging/notifications/NotificationApiTest.tsx create mode 100644 frontend/src/features/debugging/notifications/NotificationDemo.tsx create mode 100644 frontend/src/features/debugging/notifications/parts/ConnectionStatus.tsx create mode 100644 frontend/src/features/debugging/notifications/parts/index.ts rename frontend/src/hooks/{index.js => index.ts} (86%) create mode 100644 frontend/src/units/notifications/RealtimeNotificationsContext.ts create mode 100644 frontend/src/units/notifications/RealtimeNotificationsProvider.tsx create mode 100644 frontend/src/units/notifications/components/ConnectingBox.tsx create mode 100644 frontend/src/units/notifications/components/ErrorBox.tsx create mode 100644 frontend/src/units/notifications/constants.ts create mode 100644 frontend/src/units/notifications/hooks/index.ts create mode 100644 frontend/src/units/notifications/hooks/useRealtimeNotifications.ts create mode 100644 frontend/src/units/notifications/hooks/useSubscription.ts create mode 100644 frontend/src/units/notifications/index.ts create mode 100644 frontend/src/units/notifications/signalRService.ts create mode 100644 frontend/src/units/notifications/types.ts create mode 100644 frontend/src/utils/uid.ts diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 714b0c4..9337416 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -1,7 +1,7 @@ - 1.4.1 + 1.4.2 Tudor Stanciu STA NetworkResurrector diff --git a/backend/ReleaseNotes.xml b/backend/ReleaseNotes.xml index 8536632..81bd9d6 100644 --- a/backend/ReleaseNotes.xml +++ b/backend/ReleaseNotes.xml @@ -230,15 +230,25 @@ .NET 8 upgrade ◾ Upgrade all projects to .NET 8 - ◾ Upgrade packages to the latest versions + ◾ Upgrade packages to the latest versions 1.4.1 2025-04-27 01:50 - NetworkResurrector UI: Migration from Create React App to Vite + NetworkResurrector UI: Migration from Create React App to Vite ◾ The frontend of the application has been migrated from Create React App to Vite, a modern build tool that significantly improves the development experience and build performance. - + + + + 1.4.2 + 2025-06-01 21:14 + + Added realtime notifications support using SignalR + ◾ The application now supports real-time notifications, allowing users to receive updates and alerts without needing to refresh the page. + ◾ This feature enhances user experience by providing immediate feedback and updates on system events, such as machine status changes or command completions. + ◾ The notifications are implemented using WebSockets (SignalR), ensuring low latency and efficient communication between the server and the client. + \ No newline at end of file diff --git a/backend/src/api/NetworkResurrector.Api.Application/CommandHandlers/WakeMachineHandler.cs b/backend/src/api/NetworkResurrector.Api.Application/CommandHandlers/WakeMachineHandler.cs index d873516..ce08fd5 100644 --- a/backend/src/api/NetworkResurrector.Api.Application/CommandHandlers/WakeMachineHandler.cs +++ b/backend/src/api/NetworkResurrector.Api.Application/CommandHandlers/WakeMachineHandler.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using NetworkResurrector.Api.Application.Extensions; using NetworkResurrector.Api.Application.Services.Abstractions; using NetworkResurrector.Api.Domain.Constants; +using NetworkResurrector.Api.Domain.Models.InternalNotifications; using NetworkResurrector.Api.Domain.Repositories; using NetworkResurrector.Api.PublishedLanguage.Commands; using NetworkResurrector.Api.PublishedLanguage.Events; @@ -19,13 +20,15 @@ namespace NetworkResurrector.Api.Application.CommandHandlers private readonly IResurrectorService _resurrectorService; private readonly INetworkRepository _repository; private readonly INotificationService _notificationService; + private readonly IMessageHubPublisher _messageHubPublisher; - public WakeMachineHandler(ILogger logger, IResurrectorService resurrectorService, INetworkRepository repository, INotificationService notificationService) + public WakeMachineHandler(ILogger logger, IResurrectorService resurrectorService, INetworkRepository repository, INotificationService notificationService, IMessageHubPublisher messageHubPublisher) { _logger=logger; _resurrectorService=resurrectorService; _repository=repository; _notificationService=notificationService; + _messageHubPublisher=messageHubPublisher; } public async Task Handle(WakeMachine command, CancellationToken cancellationToken) @@ -51,7 +54,17 @@ namespace NetworkResurrector.Api.Application.CommandHandlers } var notificationContext = machine.ToNotificationContext(result.Status, performer); - await _notificationService.Notify(NotificationType.Wake, notificationContext, cancellationToken); + var notificationResult = await _notificationService.Notify(NotificationType.Wake, notificationContext, cancellationToken); + + var to = string.Join(", ", notificationResult.To); + var ev = new EmailSent() + { + To = to, + MachineName = machine.FullMachineName, + Status = result.Status + }; + await _messageHubPublisher.Send(ev, cancellationToken); + return result; } } diff --git a/backend/src/api/NetworkResurrector.Api.Application/Extensions/EntityExtensions.cs b/backend/src/api/NetworkResurrector.Api.Application/Extensions/EntityExtensions.cs index 352f8dc..c888b79 100644 --- a/backend/src/api/NetworkResurrector.Api.Application/Extensions/EntityExtensions.cs +++ b/backend/src/api/NetworkResurrector.Api.Application/Extensions/EntityExtensions.cs @@ -1,5 +1,5 @@ using NetworkResurrector.Api.Domain.Entities; -using NetworkResurrector.Api.Domain.Models.Notifications; +using NetworkResurrector.Api.Domain.Models.ExternalNotifications; namespace NetworkResurrector.Api.Application.Extensions { diff --git a/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/IMessageHubPublisher.cs b/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/IMessageHubPublisher.cs new file mode 100644 index 0000000..6190d48 --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/IMessageHubPublisher.cs @@ -0,0 +1,13 @@ +using NetworkResurrector.Api.Domain.Models.InternalNotifications; +using System.Threading; +using System.Threading.Tasks; + +namespace NetworkResurrector.Api.Application.Services.Abstractions +{ + public interface IMessageHubPublisher + { + Task Broadcast(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage; + Task Send(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage; + Task SendToUser(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage; + } +} diff --git a/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/INotificationService.cs b/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/INotificationService.cs index 74d4905..32f4a96 100644 --- a/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/INotificationService.cs +++ b/backend/src/api/NetworkResurrector.Api.Application/Services/Abstractions/INotificationService.cs @@ -1,5 +1,5 @@ using NetworkResurrector.Api.Domain.Constants; -using NetworkResurrector.Api.Domain.Models.Notifications; +using NetworkResurrector.Api.Domain.Models.ExternalNotifications; using System.Threading; using System.Threading.Tasks; @@ -7,7 +7,7 @@ namespace NetworkResurrector.Api.Application.Services.Abstractions { public interface INotificationService { - Task Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default); + Task Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default); Task Notify(NotificationType type, string machineName, string machineFullName, string machineIP, string actionStatus, string actionPerformer, string errorMessage, CancellationToken cancellationToken = default); Task NotifyError(string errorMessage, CancellationToken cancellationToken = default); } diff --git a/backend/src/api/NetworkResurrector.Api.Application/Services/NotificationService.cs b/backend/src/api/NetworkResurrector.Api.Application/Services/NotificationService.cs index ba33630..b26ff2c 100644 --- a/backend/src/api/NetworkResurrector.Api.Application/Services/NotificationService.cs +++ b/backend/src/api/NetworkResurrector.Api.Application/Services/NotificationService.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; using NBB.Messaging.Abstractions; using NetworkResurrector.Api.Application.Services.Abstractions; using NetworkResurrector.Api.Domain.Constants; -using NetworkResurrector.Api.Domain.Models.Notifications; +using NetworkResurrector.Api.Domain.Models.ExternalNotifications; using System; using System.Linq; using System.Text.RegularExpressions; @@ -96,7 +96,7 @@ namespace NetworkResurrector.Api.Application.Services return placeHolderValue; } - public async Task Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default) + public async Task Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default) { var notification = GetNotification(type, context); var cmd = new SendEmail() @@ -108,6 +108,9 @@ namespace NetworkResurrector.Api.Application.Services }; await _messageBusPublisher.PublishAsync(cmd, cancellationToken); + + var result = NotificationResult.Create(notification.To); + return result; } public async Task NotifyError(string errorMessage, CancellationToken cancellationToken = default) diff --git a/backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/Notification.cs b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/Notification.cs similarity index 70% rename from backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/Notification.cs rename to backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/Notification.cs index 3cd0a23..f37efa8 100644 --- a/backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/Notification.cs +++ b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/Notification.cs @@ -1,4 +1,4 @@ -namespace NetworkResurrector.Api.Domain.Models.Notifications +namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications { public record Notification { diff --git a/backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/NotificationContext.cs b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationContext.cs similarity index 82% rename from backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/NotificationContext.cs rename to backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationContext.cs index c7bc78f..da4bab7 100644 --- a/backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/NotificationContext.cs +++ b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationContext.cs @@ -1,4 +1,4 @@ -namespace NetworkResurrector.Api.Domain.Models.Notifications +namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications { public record NotificationContext { diff --git a/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationResult.cs b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationResult.cs new file mode 100644 index 0000000..444ca58 --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationResult.cs @@ -0,0 +1,15 @@ +namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications +{ + public record NotificationResult + { + public string[] To { get; init; } + + public static NotificationResult Create(string[] to) + { + return new NotificationResult + { + To = to + }; + } + } +} diff --git a/backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/NotificationTemplate.cs b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationTemplate.cs similarity index 71% rename from backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/NotificationTemplate.cs rename to backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationTemplate.cs index 9ba4f6b..ee1cb9e 100644 --- a/backend/src/api/NetworkResurrector.Api.Domain/Models/Notifications/NotificationTemplate.cs +++ b/backend/src/api/NetworkResurrector.Api.Domain/Models/ExternalNotifications/NotificationTemplate.cs @@ -1,4 +1,4 @@ -namespace NetworkResurrector.Api.Domain.Models.Notifications +namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications { public record NotificationTemplate { diff --git a/backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/EmailSent.cs b/backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/EmailSent.cs new file mode 100644 index 0000000..f0e4d3b --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/EmailSent.cs @@ -0,0 +1,9 @@ +namespace NetworkResurrector.Api.Domain.Models.InternalNotifications +{ + public record EmailSent : IRealtimeMessage + { + public string To { get; init; } + public string MachineName { get; init; } + public string Status { get; init; } + } +} diff --git a/backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/RealtimeNotification.cs b/backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/RealtimeNotification.cs new file mode 100644 index 0000000..ba0c928 --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api.Domain/Models/InternalNotifications/RealtimeNotification.cs @@ -0,0 +1,22 @@ +namespace NetworkResurrector.Api.Domain.Models.InternalNotifications +{ + public interface IRealtimeMessage { } + + public record RealtimeNotification where TPayload : IRealtimeMessage + { + public string Type { get; init; } + public TPayload Payload { get; init; } + public string SourceId { get; init; } + + public static RealtimeNotification Create(TPayload payload, string sourceId) + { + var type = typeof(TPayload).FullName ?? "UnknownType"; + return new RealtimeNotification + { + Type = type, + Payload = payload, + SourceId = sourceId + }; + } + } +} diff --git a/backend/src/api/NetworkResurrector.Api/Controllers/RealtimeNotificationsController.cs b/backend/src/api/NetworkResurrector.Api/Controllers/RealtimeNotificationsController.cs new file mode 100644 index 0000000..fe41846 --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api/Controllers/RealtimeNotificationsController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NetworkResurrector.Api.Application.Services.Abstractions; +using NetworkResurrector.Api.Domain.Models.InternalNotifications; +using System.Threading.Tasks; + +namespace NetworkResurrector.Api.Controllers +{ + [ApiController] + [Authorize] + [Route("api/realtime-notifications")] + public class RealtimeNotificationsController : ControllerBase + { + private readonly IMessageHubPublisher _messageHubPublisher; + private readonly ILogger _logger; + + public RealtimeNotificationsController(IMessageHubPublisher messageHubPublisher, ILogger logger) + { + _messageHubPublisher = messageHubPublisher; + _logger = logger; + } + + [HttpPost("broadcast")] + public async Task Broadcast([FromBody] MessageModel message) + { + await _messageHubPublisher.Broadcast(message); + return Ok(); + } + + [HttpPost("message-to-client")] + public async Task SendMessageToClient([FromBody] MessageModel message) + { + await _messageHubPublisher.Send(message); + return Ok(); + } + + [HttpPost("message-to-user")] + public async Task SendMessageToUser([FromBody] MessageModel message) + { + await _messageHubPublisher.SendToUser(message); + return Ok(); + } + } + + public class MessageModel : IRealtimeMessage + { + } +} diff --git a/backend/src/api/NetworkResurrector.Api/Extensions/HttpRequestExtensions.cs b/backend/src/api/NetworkResurrector.Api/Extensions/HttpRequestExtensions.cs new file mode 100644 index 0000000..15fa85b --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api/Extensions/HttpRequestExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; + +namespace NetworkResurrector.Api.Extensions +{ + public static class HttpRequestExtensions + { + private const string SignalRConnectionIdHeader = "SignalR-ConnectionId"; + + /// + /// Gets the SignalR connection ID from the request headers if present + /// + /// The HTTP request + /// The SignalR connection ID or null if not present + public static string GetSignalRConnectionId(this HttpRequest request) + { + if (request.Headers.TryGetValue(SignalRConnectionIdHeader, out var values)) + { + return values.ToString(); + } + + return null; + } + } +} diff --git a/backend/src/api/NetworkResurrector.Api/Extensions/SignalRExtensions.cs b/backend/src/api/NetworkResurrector.Api/Extensions/SignalRExtensions.cs new file mode 100644 index 0000000..602d3f3 --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api/Extensions/SignalRExtensions.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using NetworkResurrector.Api.Application.Services.Abstractions; +using NetworkResurrector.Api.Services; +using System; +using System.Security.Claims; + +namespace NetworkResurrector.Api.Extensions +{ + public static class SignalRExtensions + { + private const string AUTH_QUERY_STRING_KEY = "access_token"; + + /// + /// Middleware that extracts a bearer token from the query string (using the "access_token" key) + /// and injects it into the Authorization header. This is necessary for SignalR connections, + /// which cannot send authentication tokens via headers during the WebSocket handshake. + /// Enables standard authentication handlers to process SignalR requests. + /// + public static void UseSignalRAuthentication(this IApplicationBuilder app) + { + app.Use(async (context, next) => + { + var headers = context.Request.Headers; + if (string.IsNullOrWhiteSpace(headers[HeaderNames.Authorization]) && + context.Request.Query.TryGetValue(AUTH_QUERY_STRING_KEY, out var token) && + !string.IsNullOrWhiteSpace(token)) + { + try + { + // Overwrite or set the Authorization header with the Bearer token from the query string + headers[HeaderNames.Authorization] = $"Tuitio {token}"; + } + catch (InvalidOperationException) + { + // Ignore if setting the header fails (e.g., if headers are read-only at this point) + } + } + + await next.Invoke(); + }); + } + + /// + /// Gets the user identifier for the current connection + /// + /// HubCallerContext + /// The user id or null if not authenticated + public static string GetUserId(this HubCallerContext context) + { + return context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + + /// + /// Gets the username for the current connection + /// + /// HubCallerContext + /// The username or null if not authenticated + public static string GetUsername(this HubCallerContext context) + { + return context.User?.FindFirst(ClaimTypes.Name)?.Value; + } + + public static void AddSignalRNotifications(this IServiceCollection services) + { + services.AddSignalR(); + services.AddScoped(); + } + } +} diff --git a/backend/src/api/NetworkResurrector.Api/Extensions/StartupExtensions.cs b/backend/src/api/NetworkResurrector.Api/Extensions/StartupExtensions.cs index 93be7a0..7ec2975 100644 --- a/backend/src/api/NetworkResurrector.Api/Extensions/StartupExtensions.cs +++ b/backend/src/api/NetworkResurrector.Api/Extensions/StartupExtensions.cs @@ -13,18 +13,22 @@ using NetworkResurrector.Agent.Wrapper; using NetworkResurrector.Api.Application; using NetworkResurrector.Api.Authorization; using NetworkResurrector.Api.Domain.Data; +using NetworkResurrector.Api.Hubs; using NetworkResurrector.Server.Wrapper; using Newtonsoft.Json; namespace NetworkResurrector.Api.Extensions { public static class StartupExtensions - { + { public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration) { services.AddControllers() .AddNewtonsoftJson(o => o.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc); + // Add realtime notifications + services.AddSignalRNotifications(); + // Add basic authentication services.AddTuitioAuthentication(configuration.GetSection("Tuitio")["BaseAddress"]); @@ -62,18 +66,24 @@ namespace NetworkResurrector.Api.Extensions services.AddMessageBus(configuration); } - public static void Configure(this IApplicationBuilder app) - { - // global cors policy - app.UseCors(x => x.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); + public static void Configure(this IApplicationBuilder app, IConfiguration configuration) + { + var origins = configuration.GetSection("AllowedOrigins").Get(); + app.UseCors(x => x + .WithOrigins(origins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); app.UseExceptionHandler("/error"); + app.UseSignalRAuthentication(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); + endpoints.MapHub("/hubs/notifications"); }); app.ConfigureSwagger("NetworkResurrector API"); diff --git a/backend/src/api/NetworkResurrector.Api/Hubs/MessageHub.cs b/backend/src/api/NetworkResurrector.Api/Hubs/MessageHub.cs new file mode 100644 index 0000000..7729d9a --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api/Hubs/MessageHub.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using NetworkResurrector.Api.Extensions; + +namespace NetworkResurrector.Api.Hubs +{ + [Authorize] + public class MessageHub : Hub + { + private readonly ILogger _logger; + + public MessageHub(ILogger logger) + { + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + var userId = Context.GetUserId(); + var username = Context.GetUsername(); + + _logger.LogInformation($"Client connected: ConnectionId={Context.ConnectionId}, UserId={userId}, Username={username}"); + + if (!string.IsNullOrEmpty(username)) + { + var groupName = GetGroupName(username); + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + _logger.LogInformation($"Added connection {Context.ConnectionId} to user group {groupName}"); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = Context.GetUserId(); + var username = Context.GetUsername(); + + _logger.LogInformation($"Client disconnected: ConnectionId={Context.ConnectionId}, UserId={userId}, Username={username}"); + + if (!string.IsNullOrEmpty(username)) + { + var groupName = GetGroupName(username); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + _logger.LogInformation($"Removed connection {Context.ConnectionId} from user group {groupName}"); + } + + await base.OnDisconnectedAsync(exception); + } + + private string GetGroupName(string id) => $"User_{id}"; + + public async Task SendMessage(string message) + { + _logger.LogInformation($"Received message from {Context.ConnectionId}: {message}"); + await Clients.All.SendAsync("ReceiveMessage", message); + } + + public async Task SendNotification(string title, string message) + { + _logger.LogInformation($"Sending notification: {title} - {message}"); + await Clients.All.SendAsync("ReceiveNotification", title, message); + } + + public async Task JoinGroup(string groupName) + { + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + _logger.LogInformation($"Client {Context.ConnectionId} joined group: {groupName}"); + await Clients.Group(groupName).SendAsync("GroupNotification", $"User joined {groupName} group"); + } + + public async Task SendToGroup(string groupName, string message) + { + _logger.LogInformation($"Message to group {groupName}: {message}"); + await Clients.Group(groupName).SendAsync("ReceiveGroupMessage", groupName, message); + } + + public async Task SendToClient(string connectionId, string message) + { + _logger.LogInformation($"Sending message to client {connectionId}: {message}"); + await Clients.Client(connectionId).SendAsync("ReceiveMessage", message); + } + + public async Task SendToUser(string userId, string title, string message) + { + var senderUserId = Context.GetUserId(); + var senderUsername = Context.GetUsername(); + + _logger.LogInformation($"Sending notification from user {senderUserId} ({senderUsername}) to user {userId}: {title} - {message}"); + await Clients.User(userId).SendAsync("ReceiveNotification", title, message); + } + } +} diff --git a/backend/src/api/NetworkResurrector.Api/NetworkResurrector.Api.csproj b/backend/src/api/NetworkResurrector.Api/NetworkResurrector.Api.csproj index 2b99749..5b5f96e 100644 --- a/backend/src/api/NetworkResurrector.Api/NetworkResurrector.Api.csproj +++ b/backend/src/api/NetworkResurrector.Api/NetworkResurrector.Api.csproj @@ -13,6 +13,7 @@ + diff --git a/backend/src/api/NetworkResurrector.Api/Program.cs b/backend/src/api/NetworkResurrector.Api/Program.cs index e4f4df1..1687d9d 100644 --- a/backend/src/api/NetworkResurrector.Api/Program.cs +++ b/backend/src/api/NetworkResurrector.Api/Program.cs @@ -26,7 +26,7 @@ namespace NetworkResurrector.Api builder.Services.ConfigureServices(builder.Configuration); var app = builder.Build(); - app.Configure(); + app.Configure(builder.Configuration); var exitCode = 0; try diff --git a/backend/src/api/NetworkResurrector.Api/Services/MessageHubPublisher.cs b/backend/src/api/NetworkResurrector.Api/Services/MessageHubPublisher.cs new file mode 100644 index 0000000..7b2d26c --- /dev/null +++ b/backend/src/api/NetworkResurrector.Api/Services/MessageHubPublisher.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using NetworkResurrector.Api.Application.Services.Abstractions; +using NetworkResurrector.Api.Domain.Models.InternalNotifications; +using NetworkResurrector.Api.Hubs; +using System; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace NetworkResurrector.Api.Services +{ + public class MessageHubPublisher : IMessageHubPublisher + { + private const string ConnectionIdHeader = "SignalR-ConnectionId"; + + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + private readonly string _connectionId; + private readonly string _userId; + + public MessageHubPublisher(IHubContext hubContext, ILogger logger, IHttpContextAccessor httpContextAccessor) + { + _hubContext = hubContext; + _logger = logger; + + var headers = httpContextAccessor.HttpContext?.Request.Headers; + if (headers.TryGetValue(ConnectionIdHeader, out var connectionIdValue) == true) + _connectionId = connectionIdValue.ToString(); + else + _connectionId = string.Empty; + + _userId = httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + + /// + /// Broadcasts a message to all connected clients. + /// + public async Task Broadcast(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage + { + if (message is null) + throw new ArgumentNullException(nameof(message), "Message cannot be null"); + + var notification = RealtimeNotification.Create(message, _connectionId); + _logger.LogInformation($"Broadcasting message of type: {notification.Type}."); + + await _hubContext.Clients.All.SendAsync("ReceiveNotification", notification, cancellationToken); + } + + /// + /// Sends a message to the connected client identified by the connection ID. + /// + public async Task Send(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage + { + if (message is null) + throw new ArgumentNullException(nameof(message), "Message cannot be null"); + + var notification = RealtimeNotification.Create(message, _connectionId); + _logger.LogInformation($"Sending message of type: {notification.Type}."); + + await _hubContext.Clients.Client(_connectionId).SendAsync("ReceiveNotification", notification, cancellationToken); + } + + /// + /// Sends a message to the authenticated user identified by the user ID. + /// + public async Task SendToUser(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage + { + if (message is null) + throw new ArgumentNullException(nameof(message), "Message cannot be null"); + + if (string.IsNullOrEmpty(_userId)) + throw new InvalidOperationException("User ID is not available. Ensure the user is authenticated."); + + var notification = RealtimeNotification.Create(message, _connectionId); + _logger.LogInformation($"Sending message of type: {notification.Type}."); + + await _hubContext.Clients.User(_userId).SendAsync("ReceiveNotification", notification, cancellationToken); + } + } +} diff --git a/backend/src/api/NetworkResurrector.Api/appsettings.json b/backend/src/api/NetworkResurrector.Api/appsettings.json index a6b0183..142bc35 100644 --- a/backend/src/api/NetworkResurrector.Api/appsettings.json +++ b/backend/src/api/NetworkResurrector.Api/appsettings.json @@ -20,6 +20,9 @@ } }, "AllowedHosts": "*", + "AllowedOrigins": [ + "http://localhost:3000" + ], "Service": { "Code": "NETWORK_RESURRECTOR_API" }, diff --git a/frontend/.env b/frontend/.env index 1a9aa91..afadcea 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,5 +1,5 @@ VITE_APP_TUITIO_URL=https:// -VITE_APP_NETWORK_RESURRECTOR_API_URL=https:// +VITE_APP_API_URL=https:// #600000 milliseconds = 10 minutes VITE_APP_MACHINE_PING_INTERVAL=600000 diff --git a/frontend/.env.production b/frontend/.env.production index c32226b..ce28d06 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,6 +1,6 @@ VITE_APP_BASE_URL= VITE_APP_TUITIO_URL=https:// -VITE_APP_NETWORK_RESURRECTOR_API_URL=https:// +VITE_APP_API_URL=https:// #900000 milliseconds = 15 minutes VITE_APP_MACHINE_PING_INTERVAL=900000 diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 0c2a370..e9a43b8 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1,7 +1,7 @@ { "bracketSpacing": true, "arrowParens": "avoid", - "printWidth": 120, + "printWidth": 160, "trailingComma": "none", "singleQuote": false, "endOfLine": "auto" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b27721..cc3f93b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,19 @@ { "name": "network-resurrector-frontend", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "network-resurrector-frontend", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@flare/tuitio-client-react": "^1.3.0", "@flare/utiliyo": "^1.2.1", + "@microsoft/signalr": "^8.0.7", "@mui/icons-material": "^7.0.2", "@mui/lab": "^7.0.0-beta.11", "@mui/material": "^7.0.2", @@ -23,6 +24,7 @@ "i18next-http-backend": "^3.0.2", "lodash": "^4.17.21", "moment": "^2.30.1", + "nanoid": "^5.1.5", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.5.1", @@ -1332,6 +1334,19 @@ "node": ">= 8" } }, + "node_modules/@microsoft/signalr": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.0.2.tgz", @@ -2329,6 +2344,18 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -3729,6 +3756,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3797,6 +3842,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/fetch-readablestream": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/fetch-readablestream/-/fetch-readablestream-0.2.0.tgz", @@ -5011,9 +5066,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", "funding": [ { "type": "github", @@ -5022,10 +5077,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -5400,6 +5455,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5462,16 +5535,33 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5766,6 +5856,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6397,6 +6493,21 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -6617,6 +6728,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6657,6 +6777,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", @@ -7051,6 +7181,27 @@ "resolved": "https://registry.npmjs.org/world-countries/-/world-countries-5.1.0.tgz", "integrity": "sha512-CXR6EBvTbArDlDDIWU3gfKb7Qk0ck2WNZ234b/A0vuecPzIfzzxH+O6Ejnvg1sT8XuiZjVlzOH0h08ZtaO7g0w==" }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9e624ed..1375b58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "network-resurrector-frontend", - "version": "1.4.1", + "version": "1.4.2", "description": "Frontend component of Network resurrector system", "type": "module", "author": { @@ -19,6 +19,7 @@ "@emotion/styled": "^11.14.0", "@flare/tuitio-client-react": "^1.3.0", "@flare/utiliyo": "^1.2.1", + "@microsoft/signalr": "^8.0.7", "@mui/icons-material": "^7.0.2", "@mui/lab": "^7.0.0-beta.11", "@mui/material": "^7.0.2", @@ -29,6 +30,7 @@ "i18next-http-backend": "^3.0.2", "lodash": "^4.17.21", "moment": "^2.30.1", + "nanoid": "^5.1.5", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.5.1", diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 6da3d5f..8076f4b 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -22,8 +22,12 @@ "Dashboard": "Dashboard", "Machines": "Machines", "System": "System", - "Administration": "Administration", + "Administration": { "Title": "Administration", "Machines": "Machines", "Agents": "Agents" }, "Settings": "Settings", + "Debugging": { + "Title": "Debugging", + "Notifications": "Notifications" + }, "About": "About" }, "ViewModes": { diff --git a/frontend/public/locales/ro/translations.json b/frontend/public/locales/ro/translations.json index 411577c..b39e435 100644 --- a/frontend/public/locales/ro/translations.json +++ b/frontend/public/locales/ro/translations.json @@ -13,8 +13,12 @@ "Dashboard": "Bord", "Machines": "Mașini", "System": "Sistem", - "Administration": "Administrare", + "Administration": { "Title": "Administrare", "Machines": "Mașini", "Agents": "Agenți" }, "Settings": "Setări", + "Debugging": { + "Title": "Depanare", + "Notifications": "Notificări" + }, "About": "Despre" }, "ViewModes": { diff --git a/frontend/src/components/common/Hint.tsx b/frontend/src/components/common/Hint.tsx new file mode 100644 index 0000000..a82a283 --- /dev/null +++ b/frontend/src/components/common/Hint.tsx @@ -0,0 +1,21 @@ +import { Tooltip, TooltipProps, styled } from "@mui/material"; + +const Hint = styled(({ className, ...props }: TooltipProps) => )(({ theme }) => ({ + [`& .MuiTooltip-tooltip`]: { + backgroundColor: theme.palette.mode === "dark" ? "rgba(48, 48, 48, 0.95)" : "rgba(255, 255, 255, 0.95)", + color: theme.palette.text.primary, + border: `1px solid ${theme.palette.divider}`, + backdropFilter: "blur(8px)", + boxShadow: theme.shadows[8], + //fontSize: theme.typography.body2.fontSize, + borderRadius: theme.shape.borderRadius + }, + [`& .MuiTooltip-arrow`]: { + color: theme.palette.mode === "dark" ? "rgba(48, 48, 48, 0.95)" : "rgba(255, 255, 255, 0.95)", + "&::before": { + border: `1px solid ${theme.palette.divider}` + } + } +})); + +export default Hint; diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index 30d42e9..426159c 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -1,5 +1,6 @@ import DataLabel from "./DataLabel"; import PaperTitle from "./PaperTitle"; import FlagIcon from "./FlagIcon"; +import Hint from "./Hint"; -export { DataLabel, PaperTitle, FlagIcon }; +export { DataLabel, PaperTitle, FlagIcon, Hint }; diff --git a/frontend/src/components/icons/list.ts b/frontend/src/components/icons/list.ts index 6b8ba13..781e441 100644 --- a/frontend/src/components/icons/list.ts +++ b/frontend/src/components/icons/list.ts @@ -1 +1,15 @@ -export { Home, Dashboard, Dns, DeviceHub, Build, Settings, FeaturedPlayList, Info } from "@mui/icons-material"; +export { + Home, + Dashboard, + Dns, + Hub, + DeviceHub, + Build, + Settings, + FeaturedPlayList, + Info, + Adb, + Notifications, + Devices, + Stream +} from "@mui/icons-material"; diff --git a/frontend/src/components/layout/AppRoutes.tsx b/frontend/src/components/layout/AppRoutes.tsx index 7f5da1a..aaf1932 100644 --- a/frontend/src/components/layout/AppRoutes.tsx +++ b/frontend/src/components/layout/AppRoutes.tsx @@ -7,6 +7,7 @@ import SettingsContainer from "../../features/settings/SettingsContainer"; import DashboardContainer from "../../features/dashboard/DashboardContainer"; import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer"; import AboutContainer from "../../features/about/AboutContainer"; +import NotificationDemo from "features/debugging/notifications/NotificationDemo"; const AppRoutes: React.FC = () => { return ( @@ -16,6 +17,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/layout/SideBar.tsx b/frontend/src/components/layout/SideBar.tsx index aeead41..bf4b76e 100644 --- a/frontend/src/components/layout/SideBar.tsx +++ b/frontend/src/components/layout/SideBar.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; import { styled, Theme, CSSObject } from "@mui/material/styles"; import MuiDrawer from "@mui/material/Drawer"; import List from "@mui/material/List"; @@ -67,8 +67,6 @@ const SideBar: React.FC = ({ open, onDrawerOpen, onDrawerClose }) const navigate = useNavigate(); const { t } = useTranslation(); - menu.sort((a, b) => (a.order || 0) - (b.order || 0)); - return ( diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 4e4378d..7c2ff78 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -8,6 +8,7 @@ import SensitiveInfoToggle from "./SensitiveInfoToggle"; import { styled } from "@mui/material/styles"; import { drawerWidth } from "./constants"; import { ProgressBar } from "units/progress"; +import AppNotifications from "./notifications/AppNotifications"; interface AppBarProps extends MuiAppBarProps { open?: boolean; @@ -60,6 +61,7 @@ const TopBar: React.FC = ({ open, onDrawerOpen }) => { + diff --git a/frontend/src/components/layout/constants/index.ts b/frontend/src/components/layout/constants/index.ts index 2e4ad4c..2b1fd07 100644 --- a/frontend/src/components/layout/constants/index.ts +++ b/frontend/src/components/layout/constants/index.ts @@ -1,3 +1,4 @@ -export * from "./menu"; +import menu from "./menu"; export const drawerWidth = 240; +export { menu }; diff --git a/frontend/src/components/layout/constants/menu.tsx b/frontend/src/components/layout/constants/menu.tsx index 93f61b3..5f048e2 100644 --- a/frontend/src/components/layout/constants/menu.tsx +++ b/frontend/src/components/layout/constants/menu.tsx @@ -1,5 +1,8 @@ import React from "react"; -import { Dashboard, Dns, DeviceHub, Build, Settings, Info } from "../../icons"; +import { Dashboard, Dns, DeviceHub, Build, Settings, Info, Adb, Notifications, Devices, Stream } from "../../icons"; +import env from "utils/env"; + +const isDevelopment = import.meta.env.DEV || env.NODE_ENV === "development"; type MenuItem = { code: string; @@ -8,6 +11,7 @@ type MenuItem = { icon: React.ReactElement; order: number; subMenus?: MenuItem[]; + hidden?: boolean; }; type MenuSection = { @@ -49,23 +53,23 @@ const menu: Menu = [ items: [ { code: "administration", - name: "Menu.Administration", + name: "Menu.Administration.Title", route: "/administration", icon: , order: 0, subMenus: [ { code: "machines", - name: "Menu.Machines", + name: "Menu.Administration.Machines", route: "/administration/machines", - icon: , + icon: , order: 0 }, { code: "agents", - name: "Menu.Agents", + name: "Menu.Administration.Agents", route: "/administration/agents", - icon: , + icon: , order: 1 } ] @@ -76,6 +80,23 @@ const menu: Menu = [ route: "/settings", icon: , order: 1 + }, + { + code: "debugging", + name: "Menu.Debugging.Title", + route: "/debugging", + icon: , + order: 2, + hidden: !isDevelopment, + subMenus: [ + { + code: "notifications", + name: "Menu.Debugging.Notifications", + route: "/debugging/notifications", + icon: , + order: 0 + } + ] } ] }, @@ -93,6 +114,17 @@ const menu: Menu = [ } ]; +const filterAndSortMenu = (menuData: Menu): MenuSection[] => { + return menuData + .map(section => ({ + ...section, + items: section.items.filter(item => !item.hidden) + })) + .filter(section => section.items.length > 0) + .sort((a, b) => (a.order || 0) - (b.order || 0)); +}; + +const filteredMenu = filterAndSortMenu(menu); + export type { MenuItem, MenuSection, Menu }; -export { menu }; -export default menu; +export default filteredMenu; diff --git a/frontend/src/components/layout/notifications/AppNotifications.tsx b/frontend/src/components/layout/notifications/AppNotifications.tsx new file mode 100644 index 0000000..d6967be --- /dev/null +++ b/frontend/src/components/layout/notifications/AppNotifications.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useState } from "react"; +import { Badge, IconButton, Popover } from "@mui/material"; +import { Notifications } from "@mui/icons-material"; +import PopoverContent from "./PopoverContent"; +import { AppNotificationPayload, AppNotification, NotificationLevel, EmailSentPayload } from "./types"; +import { shortid } from "utils/uid"; +import { Hint } from "components/common"; +import { RealtimeNotification } from "units/notifications/types"; +import { notificationTypes } from "../../../constants"; +import { useSubscription } from "hooks"; + +const UNREAD_NOTIFICATIONS_DOT_LIMIT = 9; + +type Props = { + disabled?: boolean; +}; + +const AppNotifications: React.FC = ({ disabled }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [notifications, setNotifications] = useState([]); + + const push = useCallback((notification: AppNotification) => { + setNotifications(prev => [...prev, notification]); + }, []); + + const handleNotificationReceive = useCallback( + (notification: RealtimeNotification) => { + const n: AppNotification = { + ...notification.payload, + id: shortid(), + moment: notification.payload.moment ?? new Date(), + level: notification.payload.level ?? NotificationLevel.INFO, + read: false + }; + push(n); + }, + [push] + ); + + useSubscription(notificationTypes.APP_NOTIFICATION_RECEIVED, { + onNotification: handleNotificationReceive + }); + + useSubscription(notificationTypes.EMAIL_SENT, { + onNotification: notification => { + const n: AppNotification = { + id: shortid(), + moment: new Date(), + level: NotificationLevel.INFO, + content: `Email was sent to ${notification.payload.to} informing that the machine ${notification.payload.machineName} has been woken up.`, + read: false + }; + push(n); + } + }); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleRemove = useCallback((id: string) => { + setNotifications(prev => prev.filter(notification => notification.id !== id)); + }, []); + + const handleMarkAllAsRead = useCallback(() => { + setNotifications(prev => prev.map(notification => ({ ...notification, read: true }))); + }, []); + + const handleClose = () => { + handleMarkAllAsRead(); + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const id = open ? "app-notifications-popover" : undefined; + const unread = notifications.filter(notification => !notification.read).length; + const variant = unread > UNREAD_NOTIFICATIONS_DOT_LIMIT ? "dot" : "standard"; + + return ( + <> + + + + + + + + + + + + ); +}; + +export default AppNotifications; diff --git a/frontend/src/components/layout/notifications/PopoverContent.tsx b/frontend/src/components/layout/notifications/PopoverContent.tsx new file mode 100644 index 0000000..a60deb7 --- /dev/null +++ b/frontend/src/components/layout/notifications/PopoverContent.tsx @@ -0,0 +1,173 @@ +import React, { useMemo, useState } from "react"; +import { Avatar, Box, IconButton, List, ListItem, ListItemAvatar, ListItemText, Tooltip, Typography } from "@mui/material"; +import { AppNotification, NotificationLevel } from "./types"; +import { + MarkEmailReadOutlined, + CloseOutlined, + InfoOutlined, + CheckCircleOutlined, + ReportOutlined, + ErrorOutlineOutlined, + MarkEmailUnreadOutlined, + EmailOutlined +} from "@mui/icons-material"; +import { styled } from "@mui/material/styles"; + +const PrimarySpan = styled("span")(({ theme }) => ({ + color: theme.palette.primary.main +})); + +type Props = { + notifications: AppNotification[]; + onRemove: (id: string) => void; + onMarkAllAsRead: () => void; +}; + +const PopoverContent: React.FC = ({ notifications, onRemove, onMarkAllAsRead }) => { + const [viewUnreadOnly, setViewUnreadOnly] = useState(false); + + const isEmpty = notifications.length === 0; + + const filteredNotifications = useMemo(() => { + const list = viewUnreadOnly ? notifications.filter(n => !n.read) : [...notifications]; + list.sort((a, b) => b.moment.getTime() - a.moment.getTime()); + return list; + }, [viewUnreadOnly, notifications]); + + return ( + <> + + + Notifications + + + + + setViewUnreadOnly(prev => !prev)} size="small" color="inherit" disabled={isEmpty}> + {viewUnreadOnly ? : } + + + + + + + + + + + + + {isEmpty ? ( + + + There are no notifications + + + ) : ( + + + {filteredNotifications.map(notification => ( + + onRemove(notification.id)} size="small"> + + + + } + > + {getNotificationContent(notification)} + + ))} + + + )} + + ); +}; + +const getNotificationContent = (notification: AppNotification): React.ReactElement | null => { + const isSuccess = notification.level === NotificationLevel.SUCCESS; + const isWarning = notification.level === NotificationLevel.WARNING; + const isError = notification.level === NotificationLevel.ERROR; + + const RowIcon = isSuccess ? ( + + ) : isWarning ? ( + + ) : isError ? ( + + ) : ( + + ); + + return ( + <> + + {RowIcon} + + + + {notification.author && {notification.author} } + {notification.title} + + {notification.content} + + } + secondary={ + + {notification.moment.toLocaleString()} + + } + sx={{ my: 0 }} + /> + + ); +}; + +export default PopoverContent; diff --git a/frontend/src/components/layout/notifications/types.ts b/frontend/src/components/layout/notifications/types.ts new file mode 100644 index 0000000..d1f9da1 --- /dev/null +++ b/frontend/src/components/layout/notifications/types.ts @@ -0,0 +1,29 @@ +export enum NotificationLevel { + INFO = "info", + SUCCESS = "success", + WARNING = "warning", + ERROR = "error" +} + +export type AppNotificationPayload = { + title?: string; + content: string; + author?: string; + moment?: Date; + level?: NotificationLevel; +}; + +export type AppNotification = { + id: string; + title?: string; + content: string; + author?: string; + moment: Date; + level: NotificationLevel; + read: boolean; +}; + +export type EmailSentPayload = { + to: string; + machineName: string; +}; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index a8252c0..2db3b1b 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -1,6 +1,8 @@ +import * as notificationTypes from "./notificationTypes"; + const COLOR_SCHEME = { LIGHT: "light", DARK: "dark" }; -export { COLOR_SCHEME }; +export { COLOR_SCHEME, notificationTypes }; diff --git a/frontend/src/constants/notificationTypes.ts b/frontend/src/constants/notificationTypes.ts new file mode 100644 index 0000000..1ed8b70 --- /dev/null +++ b/frontend/src/constants/notificationTypes.ts @@ -0,0 +1,9 @@ +// server to client notifications +export const APP_NOTIFICATION_RECEIVED = "APP_NOTIFICATION_RECEIVED"; +export const EMAIL_SENT = "NetworkResurrector.Api.Domain.Models.InternalNotifications.EmailSent"; +export const MACHINE_DELETED = "MACHINE_DELETED"; +export const MACHINE_DELETE_FAILED = "MACHINE_DELETE_FAILED"; + +// client to server notifications +export const MACHINES_PAGE_OPENED = "MACHINES_PAGE_OPENED"; +export const MACHINES_PAGE_CLOSED = "MACHINES_PAGE_CLOSED"; diff --git a/frontend/src/features/debugging/notifications/NotificationApiTest.tsx b/frontend/src/features/debugging/notifications/NotificationApiTest.tsx new file mode 100644 index 0000000..2bf4e32 --- /dev/null +++ b/frontend/src/features/debugging/notifications/NotificationApiTest.tsx @@ -0,0 +1,153 @@ +import React, { useState } from "react"; +import { Box, Button, Card, CardContent, Grid, TextField, Typography } from "@mui/material"; +import SendIcon from "@mui/icons-material/Send"; +import axios from "axios"; +import { useRealtimeNotifications } from "units/notifications/hooks"; +import env from "utils/env"; +import { acquire as fetchTuitioData } from "@flare/tuitio-client"; + +const API_URL = env.VITE_APP_API_URL; +const getHeaders = (): Record => { + const { token } = fetchTuitioData(); + + const headers: Record = { + "Content-Type": "application/json" + }; + + if (token) { + headers.Authorization = `Tuitio ${token}`; + } + + return headers; +}; + +const headers = getHeaders(); +const axiosInstance = axios.create({ + baseURL: API_URL, + headers +}); + +const NotificationApiTest: React.FC = () => { + const { connectionId } = useRealtimeNotifications(); + const [title, setTitle] = useState(""); + const [message, setMessage] = useState(""); + const [userId, setUserId] = useState(""); + const [responseStatus, setResponseStatus] = useState<{ success: boolean; message: string } | null>(null); + + // Handle broadcasting a notification through the API + const handleBroadcastNotification = async () => { + try { + const response = await axiosInstance.post("/api/realtime-notifications/broadcast", { + title: title || "API Test Notification", + message: message || `This is a test notification sent from the API at ${new Date().toLocaleTimeString()}` + }); + + console.log("Broadcast response:", response.data); + setResponseStatus({ success: true, message: "Broadcast sent successfully" }); + setTitle(""); + setMessage(""); + } catch (error) { + console.error("Error sending broadcast:", error); + setResponseStatus({ success: false, message: "Failed to send broadcast" }); + } + }; + + // Handle sending a notification to a specific user + const handleSendToUser = async () => { + try { + const response = await axiosInstance.post("/api/Notifications/user", { + userId: userId, + title: title || "User-specific Notification", + message: message || `This notification was sent to user ${userId} at ${new Date().toLocaleTimeString()}` + }); + + console.log("User notification response:", response.data); + setResponseStatus({ success: true, message: "User notification sent successfully" }); + setTitle(""); + setMessage(""); + } catch (error) { + console.error("Error sending user notification:", error); + setResponseStatus({ success: false, message: "Failed to send user notification" }); + } + }; + + return ( + + + Test Notification API Endpoints + + + {/* Display connection ID info */} + + + {connectionId ? `Current connection ID: ${connectionId}` : "Not connected to SignalR hub. Connect first to receive notifications."} + + + + + + + + + Broadcast Notification (API) + + setTitle(e.target.value)} margin="normal" /> + setMessage(e.target.value)} margin="normal" /> + + + + + + + + + + User-specific Notification (API) + + setUserId(e.target.value)} + placeholder="Enter connection ID to receive notification" + margin="normal" + /> + setTitle(e.target.value)} margin="normal" /> + setMessage(e.target.value)} margin="normal" /> + + + + + + + {/* Response status */} + {responseStatus && ( + + + {responseStatus.message} + + + )} + + + + Note: For user-specific notifications, enter a valid connection ID in the User ID field. You can find your connection ID at the top of this page when + connected to SignalR. + + + + ); +}; + +export default NotificationApiTest; diff --git a/frontend/src/features/debugging/notifications/NotificationDemo.tsx b/frontend/src/features/debugging/notifications/NotificationDemo.tsx new file mode 100644 index 0000000..cc70431 --- /dev/null +++ b/frontend/src/features/debugging/notifications/NotificationDemo.tsx @@ -0,0 +1,358 @@ +import React, { useState, useEffect } from "react"; +import { + Alert, + Badge, + Box, + Button, + Drawer, + IconButton, + List, + ListItem, + Snackbar, + Typography, + Paper, + TextField, + Grid, + Card, + CardContent, + Chip, + Divider +} from "@mui/material"; +import NotificationsIcon from "@mui/icons-material/Notifications"; +import CloseIcon from "@mui/icons-material/Close"; +import SendIcon from "@mui/icons-material/Send"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useRealtimeNotifications } from "units/notifications/hooks"; +import { ConnectionStatus } from "./parts"; +import NotificationApiTest from "./NotificationApiTest"; + +type TestNotification = { + title: string; + message: string; +}; + +const TEST_NOTIFICATION_TYPE = "Notifications.Test"; + +const NotificationDemo: React.FC = () => { + const { notifications, messages, sendMessage, sendNotification, joinGroup, sendToGroup, sendToClient, sendToUser } = useRealtimeNotifications(); + const [open, setOpen] = useState(false); + const [openSnackbar, setOpenSnackbar] = useState(false); + const [currentNotification, setCurrentNotification] = useState({ title: "", message: "" }); + + // Form states + const [messageText, setMessageText] = useState(""); + const [notificationTitle, setNotificationTitle] = useState(""); + const [notificationMessage, setNotificationMessage] = useState(""); + const [groupName, setGroupName] = useState(""); + const [groupMessage, setGroupMessage] = useState(""); + const [joinedGroups, setJoinedGroups] = useState([]); + const [userId, setUserId] = useState(""); + const [clientConnectionId, setClientConnectionId] = useState(""); + const [userNotificationTitle, setUserNotificationTitle] = useState(""); + const [userNotificationMessage, setUserNotificationMessage] = useState(""); + + // Handle sending a test message + const handleSendTestMessage = () => { + if (messageText.trim()) { + sendMessage(messageText); + setMessageText(""); + } else { + sendMessage(`Test message at ${new Date().toLocaleTimeString()}`); + } + }; + + // Handle sending a test notification + const handleSendTestNotification = () => { + if (notificationTitle.trim() && notificationMessage.trim()) { + const payload: TestNotification = { + title: notificationTitle, + message: notificationMessage + }; + const notification = { + type: TEST_NOTIFICATION_TYPE, + payload + }; + sendNotification(notification); + setNotificationTitle(""); + setNotificationMessage(""); + } else { + const payload: TestNotification = { + title: "Test Notification", + message: `This is a test notification sent at ${new Date().toLocaleTimeString()}` + }; + + const notification = { + type: TEST_NOTIFICATION_TYPE, + payload + }; + + sendNotification(notification); + } + }; + + // Handle joining a group + const handleJoinGroup = () => { + if (groupName.trim() && !joinedGroups.includes(groupName)) { + joinGroup(groupName); + setJoinedGroups(prev => [...prev, groupName]); + } + }; + + // Handle sending a message to a group + const handleSendToGroup = () => { + if (groupName.trim() && groupMessage.trim()) { + sendToGroup(groupName, groupMessage); + setGroupMessage(""); + } + }; + + // Handle sending a notification to a specific user + const handleSendToUser = () => { + if (userId.trim() && userNotificationTitle.trim() && userNotificationMessage.trim()) { + sendToUser(userId, userNotificationTitle, userNotificationMessage); + setUserNotificationTitle(""); + setUserNotificationMessage(""); + } + }; + + const handleSendToClient = () => { + if (!clientConnectionId.trim() || !messageText.trim()) return; + sendToClient(clientConnectionId, messageText); + setClientConnectionId(""); + setMessageText(""); + }; + + // Clear all messages + const handleClearMessages = () => { + // We can't directly clear the messages in the SignalR context, + // but we can simulate a refresh + window.location.reload(); + }; + + // Show the most recent notification in a snackbar + useEffect(() => { + if (notifications.length > 0) { + const latestNotification = notifications[notifications.length - 1]; + let payload: TestNotification; + + if (latestNotification.type !== TEST_NOTIFICATION_TYPE) { + payload = { + title: "Unknown Notification", + message: `Received notification of type ${latestNotification.type}` + }; + } else { + payload = latestNotification.payload as TestNotification; + } + + setCurrentNotification(payload); + setOpenSnackbar(true); + } + }, [notifications]); + + return ( + <> + + + SignalR Demo + + setOpen(true)}> + + + + + + + + + + + + + + Send message + + setMessageText(e.target.value)} margin="normal" /> + + + + + + + + + + Send notification + + setNotificationTitle(e.target.value)} margin="normal" /> + setNotificationMessage(e.target.value)} margin="normal" /> + + + + + + + + + + Group messages + + setGroupName(e.target.value)} margin="normal" /> + + + {joinedGroups.length > 0 && ( + + + Joined groups: + + + {joinedGroups.map((group, index) => ( + + ))} + + + )} + + setGroupMessage(e.target.value)} margin="normal" /> + + + + + + + + + + Client-specific message + + setClientConnectionId(e.target.value)} margin="normal" /> + setMessageText(e.target.value)} margin="normal" /> + + + + + + + + + + User-specific notifications + + setUserId(e.target.value)} margin="normal" /> + setUserNotificationTitle(e.target.value)} margin="normal" /> + setUserNotificationMessage(e.target.value)} + margin="normal" + /> + + + + + + + + + + Received messages + + + {messages.length === 0 ? ( + + No messages received yet + + ) : ( + + {messages.map((message, index) => ( + + {message} + + ))} + + )} + + + + + + + + {/* Notification Drawer */} + setOpen(false)}> + + + Notifications + setOpen(false)}> + + + + + {notifications.length === 0 ? ( + + + No notifications + + + ) : ( + + {notifications.map((notification, index) => ( + + + + + {notification.type} + + {JSON.stringify(notification.payload)} + + + + ))} + + )} + + + + {/* Notification Snackbar */} + setOpenSnackbar(false)} anchorOrigin={{ vertical: "bottom", horizontal: "right" }}> + setOpenSnackbar(false)} severity="info" sx={{ width: "100%" }}> + {currentNotification.title} + {currentNotification.message} + + + + + + + ); +}; + +export default NotificationDemo; diff --git a/frontend/src/features/debugging/notifications/parts/ConnectionStatus.tsx b/frontend/src/features/debugging/notifications/parts/ConnectionStatus.tsx new file mode 100644 index 0000000..7d3b1b8 --- /dev/null +++ b/frontend/src/features/debugging/notifications/parts/ConnectionStatus.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { useRealtimeNotifications } from "units/notifications/hooks"; + +const ConnectionStatus: React.FC = () => { + const { isConnected, connectionId } = useRealtimeNotifications(); + return ( + + + + Connection status: + + + + {isConnected ? "Connected" : "Disconnected"} + + + {connectionId && ( + + + Connection ID: {connectionId} + + + )} + + ); +}; + +export default ConnectionStatus; diff --git a/frontend/src/features/debugging/notifications/parts/index.ts b/frontend/src/features/debugging/notifications/parts/index.ts new file mode 100644 index 0000000..530eaf7 --- /dev/null +++ b/frontend/src/features/debugging/notifications/parts/index.ts @@ -0,0 +1,3 @@ +import ConnectionStatus from "./ConnectionStatus"; + +export { ConnectionStatus }; diff --git a/frontend/src/hooks/index.js b/frontend/src/hooks/index.ts similarity index 86% rename from frontend/src/hooks/index.js rename to frontend/src/hooks/index.ts index 48f69e1..e2e41b8 100644 --- a/frontend/src/hooks/index.js +++ b/frontend/src/hooks/index.ts @@ -4,3 +4,5 @@ import { useClipboard } from "./useClipboard"; import useApplicationTheme from "./useApplicationTheme"; export { useSensitiveInfo, usePermissions, useClipboard, useApplicationTheme }; + +export * from "units/notifications/hooks"; diff --git a/frontend/src/hooks/useSensitiveInfo.js b/frontend/src/hooks/useSensitiveInfo.js index 532f13a..3166d84 100644 --- a/frontend/src/hooks/useSensitiveInfo.js +++ b/frontend/src/hooks/useSensitiveInfo.js @@ -9,7 +9,7 @@ const useSensitiveInfo = () => { const mask = text => { if (!enabled) return text; - return obfuscate(text, "◾"); + return obfuscate(text, "▪️"); }; const maskElements = list => { diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 76aa39c..5ca6c5c 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -4,7 +4,7 @@ import ThemeProvider from "./providers/ThemeProvider"; import { CssBaseline } from "@mui/material"; import AppRouter from "./components/AppRouter"; import { TuitioProvider } from "@flare/tuitio-client-react"; -import { ToastProvider } from "./providers"; +import { ToastProvider, RealtimeNotificationsProvider } from "./providers"; import env from "./utils/env"; import "./utils/i18n"; @@ -28,7 +28,9 @@ root.render( Loading...}> - + + + diff --git a/frontend/src/providers/index.js b/frontend/src/providers/index.js index d08563c..e76edba 100644 --- a/frontend/src/providers/index.js +++ b/frontend/src/providers/index.js @@ -1,5 +1,6 @@ import ThemeProvider from "./ThemeProvider"; import ToastProvider from "./ToastProvider"; import SensitiveInfoProvider from "./SensitiveInfoProvider"; +import { RealtimeNotificationsProvider } from "units/notifications"; -export { ThemeProvider, ToastProvider, SensitiveInfoProvider }; +export { ThemeProvider, ToastProvider, SensitiveInfoProvider, RealtimeNotificationsProvider }; diff --git a/frontend/src/units/notifications/RealtimeNotificationsContext.ts b/frontend/src/units/notifications/RealtimeNotificationsContext.ts new file mode 100644 index 0000000..90c5000 --- /dev/null +++ b/frontend/src/units/notifications/RealtimeNotificationsContext.ts @@ -0,0 +1,41 @@ +import { createContext } from "react"; +import { PublishFn, RealtimeNotification, SubscribeFn } from "./types"; + +export interface RealtimeNotificationsContextData { + isConnected: boolean; + connectionId: string | null; + notifications: RealtimeNotification[]; + subscribe: SubscribeFn; + publish: PublishFn; + + // nonstandard properties + messages: string[]; + sendMessage: (message: string) => Promise; + sendNotification: (notification: RealtimeNotification) => Promise; + joinGroup: (groupName: string) => Promise; + sendToGroup: (groupName: string, message: string) => Promise; + sendToClient: (connectionId: string, message: string) => Promise; + sendToUser: (userId: string, title: string, message: string) => Promise; +} + +const RealtimeNotificationsContext = createContext({ + connectionId: null, + isConnected: false, + notifications: [], + subscribe: () => { + throw new Error("subscribe function not implemented"); + }, + publish: () => { + throw new Error("publish function not implemented"); + }, + + messages: [], + sendMessage: async () => {}, + sendNotification: async () => {}, + joinGroup: async () => {}, + sendToGroup: async () => {}, + sendToClient: async () => {}, + sendToUser: async () => {} +}); + +export default RealtimeNotificationsContext; diff --git a/frontend/src/units/notifications/RealtimeNotificationsProvider.tsx b/frontend/src/units/notifications/RealtimeNotificationsProvider.tsx new file mode 100644 index 0000000..ebeea6f --- /dev/null +++ b/frontend/src/units/notifications/RealtimeNotificationsProvider.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useState, ReactNode, useCallback, useRef } from "react"; +import signalRService from "./signalRService"; +import ErrorBox from "./components/ErrorBox"; +import ConnectingBox from "./components/ConnectingBox"; +import { MAX_RETRY_ATTEMPTS, RETRY_DELAY, NOTIFICATION_HISTORY_LIMIT } from "./constants"; +import { RealtimeNotification, RealtimeNotificationListeners, SubscribeFn, PublishFn } from "./types"; +import RealtimeNotificationsContext, { RealtimeNotificationsContextData } from "./RealtimeNotificationsContext"; + +interface SignalRProviderProps { + children: ReactNode; +} + +const RealtimeNotificationsProvider: React.FC = ({ children }) => { + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(true); + const [connectionError, setConnectionError] = useState(null); + const [messages, setMessages] = useState([]); + const [notifications, setNotifications] = useState([]); + const [connectionAttempts, setConnectionAttempts] = useState(0); + + const listenersRef = useRef({}); + const queuedNotificationsRef = useRef([]); + + const safePublish: PublishFn = useCallback( + (event: RealtimeNotification) => { + if (!isConnected) return; + signalRService.sendNotification(event); + }, + [isConnected] + ); + + const publishQueuedNotifications = useCallback(() => { + queuedNotificationsRef.current.forEach(event => { + safePublish(event); + }); + queuedNotificationsRef.current = []; + }, [safePublish]); + + // a ref is used here to keep publishQueuedNotifications outside of onReconnected dependencies. + const publishQueuedNotificationsRef = useRef(publishQueuedNotifications); + publishQueuedNotificationsRef.current = publishQueuedNotifications; + + useEffect(() => { + const retryLimitReached = connectionAttempts >= MAX_RETRY_ATTEMPTS; + if (retryLimitReached) return; + + const connectToSignalR = () => { + // Start SignalR connection + signalRService.startConnection({ + onStart: () => { + setIsConnecting(true); + setConnectionError(null); + }, + onSuccess: () => { + setIsConnected(true); + setConnectionAttempts(0); + + // Set up listeners + signalRService.addMessageListener(message => { + setMessages(prev => [...prev, message]); + }); + + signalRService.addNotificationListener(notification => { + setNotifications(prev => { + const notifications = [...prev, notification]; + // Limit notification history to avoid memory issues + if (notifications.length > NOTIFICATION_HISTORY_LIMIT) { + notifications.shift(); + } + return notifications; + }); + + // Call any registered listeners for this notification type + if (listenersRef.current[notification.type]) { + listenersRef.current[notification.type].forEach(callback => callback(notification)); + } + }); + + signalRService.addGroupMessageListener((group, message) => { + setMessages(prev => [...prev, `Group ${group}: ${message}`]); + }); + }, + onError: error => { + const newAttempts = connectionAttempts + 1; + if (newAttempts >= MAX_RETRY_ATTEMPTS) { + const msg = "Failed to connect to SignalR hub after multiple attempts."; + setConnectionError(error instanceof Error ? `${msg} ${error.message}` : msg); + setIsConnecting(false); + } else { + setConnectionError(`Retrying connection (Attempt ${newAttempts} of ${MAX_RETRY_ATTEMPTS})...`); + } + setConnectionAttempts(newAttempts); + }, + onEnd: () => { + setIsConnecting(false); + } + }); + }; + + const isRetry = connectionAttempts > 0; + if (isRetry) { + const timer = setTimeout(connectToSignalR, RETRY_DELAY); + return () => { + clearTimeout(timer); + signalRService.stopConnection(); + }; + } else { + connectToSignalR(); + } + + // Check connection state changes + signalRService.connection.onreconnecting(() => { + setIsConnected(false); + setIsConnecting(true); + }); + + signalRService.connection.onreconnected(() => { + setIsConnected(true); + setIsConnecting(false); + publishQueuedNotificationsRef.current(); + }); + + signalRService.connection.onclose(() => { + setIsConnected(false); + setConnectionError("Connection closed unexpectedly"); + }); + + // Clean up on unmount + return () => { + signalRService.stopConnection(); + }; + }, [connectionAttempts]); + + const subscribe: SubscribeFn = useCallback((notificationType: string, callback: (notification: RealtimeNotification) => void) => { + if (!listenersRef.current[notificationType]) { + listenersRef.current[notificationType] = []; + } + listenersRef.current[notificationType].push(callback); + + const unsubscribe = () => { + listenersRef.current[notificationType] = listenersRef.current[notificationType].filter(cb => cb !== callback); + }; + return unsubscribe; + }, []); + + const publish: PublishFn = useCallback( + (event: RealtimeNotification) => { + if (!signalRService.connection) return; + if (signalRService.isConnecting()) { + // queue notifications while connecting/reconnecting + queuedNotificationsRef.current.push(event); + return; + } + + if (isConnected) { + return; + } + + safePublish(event); + }, + [isConnected, safePublish] + ); + + const value: RealtimeNotificationsContextData = { + connectionId: signalRService.connection.connectionId, + isConnected, + notifications, + subscribe, + publish, + messages, + sendMessage: signalRService.sendMessage, + sendNotification: signalRService.sendNotification, + joinGroup: signalRService.joinGroup, + sendToGroup: signalRService.sendToGroup, + sendToClient: signalRService.sendToClient, + sendToUser: signalRService.sendToUser + }; + + const handleRetryConnection = useCallback(() => { + setConnectionAttempts(0); + setConnectionError(null); + }, []); + + const handleAgreeAndContinue = useCallback(() => { + setConnectionError(null); + }, []); + + // Show loading indicator while connecting + if (isConnecting) { + return ; + } + + // Show error if connection failed + if (connectionError) { + const retryLimitReached = connectionAttempts >= MAX_RETRY_ATTEMPTS; + return ; + } + + // Render children only when connected or user has chosen to continue without connection. + return {children}; +}; + +export default RealtimeNotificationsProvider; diff --git a/frontend/src/units/notifications/components/ConnectingBox.tsx b/frontend/src/units/notifications/components/ConnectingBox.tsx new file mode 100644 index 0000000..58814a8 --- /dev/null +++ b/frontend/src/units/notifications/components/ConnectingBox.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Box, CircularProgress, Typography } from "@mui/material"; +import { MAX_RETRY_ATTEMPTS } from "../constants"; + +type Props = { + connectionAttempts: number; +}; + +const ConnectingBox: React.FC = ({ connectionAttempts }) => { + return ( + + + + {connectionAttempts > 0 + ? `Establishing real-time connection... (Attempt ${connectionAttempts}/${MAX_RETRY_ATTEMPTS})` + : "Establishing real-time connection..."} + + + ); +}; + +export default ConnectingBox; diff --git a/frontend/src/units/notifications/components/ErrorBox.tsx b/frontend/src/units/notifications/components/ErrorBox.tsx new file mode 100644 index 0000000..5821ec4 --- /dev/null +++ b/frontend/src/units/notifications/components/ErrorBox.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Box, Typography, Button } from "@mui/material"; + +type Props = { + message: string; + showActions: boolean; + onRetry: () => void; + onAgreeAndContinue: () => void; +}; + +const ErrorBox: React.FC = ({ message, showActions, onRetry, onAgreeAndContinue }) => { + return ( + + SignalR connection error + {message} + + The application might not function correctly without real-time updates. + + {showActions && ( + <> + + + + )} + + ); +}; + +export default ErrorBox; diff --git a/frontend/src/units/notifications/constants.ts b/frontend/src/units/notifications/constants.ts new file mode 100644 index 0000000..40d668f --- /dev/null +++ b/frontend/src/units/notifications/constants.ts @@ -0,0 +1,5 @@ +const MAX_RETRY_ATTEMPTS = 3; +const RETRY_DELAY = 5000; // 5 seconds +const NOTIFICATION_HISTORY_LIMIT = 100; + +export { MAX_RETRY_ATTEMPTS, RETRY_DELAY, NOTIFICATION_HISTORY_LIMIT }; diff --git a/frontend/src/units/notifications/hooks/index.ts b/frontend/src/units/notifications/hooks/index.ts new file mode 100644 index 0000000..ba92404 --- /dev/null +++ b/frontend/src/units/notifications/hooks/index.ts @@ -0,0 +1,4 @@ +import useRealtimeNotifications from "./useRealtimeNotifications"; +import useSubscription from "./useSubscription"; + +export { useRealtimeNotifications, useSubscription }; diff --git a/frontend/src/units/notifications/hooks/useRealtimeNotifications.ts b/frontend/src/units/notifications/hooks/useRealtimeNotifications.ts new file mode 100644 index 0000000..024e1d1 --- /dev/null +++ b/frontend/src/units/notifications/hooks/useRealtimeNotifications.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import RealtimeNotificationsContext from "../RealtimeNotificationsContext"; + +const useRealtimeNotifications = () => { + const context = useContext(RealtimeNotificationsContext); + if (!context) { + throw new Error("useRealtimeNotifications must be used within a RealtimeNotificationsProvider"); + } + return context; +}; + +export default useRealtimeNotifications; diff --git a/frontend/src/units/notifications/hooks/useSubscription.ts b/frontend/src/units/notifications/hooks/useSubscription.ts new file mode 100644 index 0000000..34923b2 --- /dev/null +++ b/frontend/src/units/notifications/hooks/useSubscription.ts @@ -0,0 +1,34 @@ +import { useEffect, useMemo } from "react"; +import { RealtimeNotification } from "../types"; +import useRealtimeNotifications from "./useRealtimeNotifications"; + +interface SubscriptionOptions { + skip?: boolean; + onNotification?: (event: RealtimeNotification) => void; +} + +const defaultOptions: Required> = { + skip: false, + onNotification: () => {} +}; + +function useSubscription(notificationType: string, options: SubscriptionOptions = defaultOptions) { + const { subscribe } = useRealtimeNotifications(); + const opts = useMemo(() => ({ ...defaultOptions, ...options }), [options]); + + useEffect(() => { + if (opts.skip) { + return; + } + + // Subscribe to the specified notification type with the provided callback + const unsubscribe = subscribe(notificationType, opts.onNotification); + + // Return an unsubscribe function to be called on component unmount + return () => { + unsubscribe(); + }; + }, [subscribe, notificationType, opts.skip, opts.onNotification]); +} + +export default useSubscription; diff --git a/frontend/src/units/notifications/index.ts b/frontend/src/units/notifications/index.ts new file mode 100644 index 0000000..42fafe6 --- /dev/null +++ b/frontend/src/units/notifications/index.ts @@ -0,0 +1,3 @@ +import RealtimeNotificationsProvider from "./RealtimeNotificationsProvider"; + +export { RealtimeNotificationsProvider }; diff --git a/frontend/src/units/notifications/signalRService.ts b/frontend/src/units/notifications/signalRService.ts new file mode 100644 index 0000000..7b0253e --- /dev/null +++ b/frontend/src/units/notifications/signalRService.ts @@ -0,0 +1,141 @@ +import * as signalR from "@microsoft/signalr"; +import env from "utils/env"; +import { acquire as fetchTuitioData } from "@flare/tuitio-client"; +import type { ConnectionStartOptions, RealtimeNotification } from "./types"; + +const API_URL = env.VITE_APP_API_URL; + +// Create a SignalR connection +const connection = new signalR.HubConnectionBuilder() + .withUrl(`${API_URL}/hubs/notifications`, { + skipNegotiation: false, + transport: signalR.HttpTransportType.WebSockets, + accessTokenFactory: () => { + const { token } = fetchTuitioData(); + if (!token) { + console.error("No token found for SignalR connection"); + return ""; + } + return token; + } + }) + .withAutomaticReconnect() // Automatically reconnect if connection is lost + .configureLogging(signalR.LogLevel.Information) + .build(); + +// Start the connection +const startConnection = async (options?: ConnectionStartOptions) => { + try { + options?.onStart?.(); + await connection.start(); + const connected = connection.state === signalR.HubConnectionState.Connected; + if (!connected) { + throw new Error("Connection did not reach connected state"); + } + console.log("SignalR Connected"); + options?.onSuccess?.(); + } catch (err) { + console.error("SignalR Connection Error: ", err); + options?.onError?.(err); + // Retry connection after a delay + //setTimeout(startConnection, 5000); + } finally { + options?.onEnd?.(); + } +}; + +const stopConnection = () => { + connection.off("ReceiveMessage"); + connection.off("ReceiveNotification"); + connection.off("ReceiveGroupMessage"); + connection.stop(); +}; + +const isConnecting = () => { + return [signalR.HubConnectionState.Connecting, signalR.HubConnectionState.Reconnecting].includes(connection.state); +}; + +// Add event handlers for common SignalR events +const addMessageListener = (callback: (message: string) => void) => { + connection.on("ReceiveMessage", message => { + callback(message); + }); +}; + +const addNotificationListener = (callback: (notification: RealtimeNotification) => void) => { + connection.on("ReceiveNotification", notification => { + callback(notification); + }); +}; + +const addGroupMessageListener = (callback: (group: string, message: string) => void) => { + connection.on("ReceiveGroupMessage", (group, message) => { + callback(group, message); + }); +}; + +// Methods to call SignalR hub methods +const sendMessage = async (message: string) => { + try { + await connection.invoke("SendMessage", message); + } catch (err) { + console.error("Error sending message: ", err); + } +}; + +const sendNotification = async (notification: RealtimeNotification) => { + try { + await connection.invoke("SendNotification", notification); + } catch (err) { + console.error("Error sending notification: ", err); + } +}; + +const joinGroup = async (groupName: string) => { + try { + await connection.invoke("JoinGroup", groupName); + } catch (err) { + console.error("Error joining group: ", err); + } +}; + +const sendToGroup = async (groupName: string, message: string) => { + try { + await connection.invoke("SendToGroup", groupName, message); + } catch (err) { + console.error("Error sending to group: ", err); + } +}; + +const sendToClient = async (connectionId: string, message: string) => { + try { + await connection.invoke("SendToClient", connectionId, message); + } catch (err) { + console.error("Error sending to client: ", err); + } +}; + +const sendToUser = async (userId: string, title: string, message: string) => { + try { + await connection.invoke("SendToUser", userId, title, message); + } catch (err) { + console.error("Error sending notification to user: ", err); + } +}; + +// Export the connection and methods +export default { + connection, + startConnection, + stopConnection, + isConnecting, + addMessageListener, + addNotificationListener, + addGroupMessageListener, + sendMessage, + sendNotification, + joinGroup, + sendToGroup, + sendToClient, + sendToUser +}; diff --git a/frontend/src/units/notifications/types.ts b/frontend/src/units/notifications/types.ts new file mode 100644 index 0000000..7012ea4 --- /dev/null +++ b/frontend/src/units/notifications/types.ts @@ -0,0 +1,20 @@ +export type ConnectionStartOptions = { + onStart?: () => void; + onError?: (error: unknown) => void; + onSuccess?: () => void; + onEnd?: () => void; +}; + +//export type RealtimeNotificationPayload = Record; + +export type RealtimeNotification = { + type: string; + payload: T; + sourceId?: string; +}; + +export type RealtimeNotificationListeners = { [key: string]: ((notification: RealtimeNotification) => void)[] }; + +export type UnsubscribeFn = () => void; +export type SubscribeFn = (notificationType: string, callback: (notification: RealtimeNotification) => void) => UnsubscribeFn; +export type PublishFn = (notification: RealtimeNotification) => void; diff --git a/frontend/src/units/swr/fetchers.ts b/frontend/src/units/swr/fetchers.ts index bec9af1..55e1157 100644 --- a/frontend/src/units/swr/fetchers.ts +++ b/frontend/src/units/swr/fetchers.ts @@ -1,10 +1,12 @@ import i18next from "i18next"; import { acquire as fetchTuitioData } from "@flare/tuitio-client"; import { NetworkError, ServerError } from "./errors"; +import signalRService from "../notifications/signalRService"; const getHeaders = (): HeadersInit => { const { token } = fetchTuitioData(); const language = i18next.language; + const connectionId = signalRService.connection.connectionId; const headers: HeadersInit = { "Content-Type": "application/json" @@ -18,6 +20,10 @@ const getHeaders = (): HeadersInit => { headers["Accept-Language"] = language; } + if (connectionId) { + headers["SignalR-ConnectionId"] = connectionId; + } + return headers; }; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 9f584f1..2b83ea0 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,6 +1,6 @@ import env from "../utils/env"; -const apiHost = env.VITE_APP_NETWORK_RESURRECTOR_API_URL; +const apiHost = env.VITE_APP_API_URL; const networkRoute = `${apiHost}/network`; const systemRoute = `${apiHost}/system`; diff --git a/frontend/src/utils/obfuscateStrings.js b/frontend/src/utils/obfuscateStrings.js index 63d36f7..67d9d3b 100644 --- a/frontend/src/utils/obfuscateStrings.js +++ b/frontend/src/utils/obfuscateStrings.js @@ -6,7 +6,7 @@ const obfuscateForChars = (text, placeholder = "*") => { }; const obfuscate = (text, placeholder = "*") => { - if (text.length <= 2) return text; + if (!text || text.length <= 2) return text; if (text.length <= 5) { return obfuscateForChars(text); } diff --git a/frontend/src/utils/uid.ts b/frontend/src/utils/uid.ts new file mode 100644 index 0000000..22a31a2 --- /dev/null +++ b/frontend/src/utils/uid.ts @@ -0,0 +1,8 @@ +import { nanoid, customAlphabet } from "nanoid"; + +const uid = (size?: number): string => nanoid(size); + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const shortid = customAlphabet(alphabet, 8); + +export { uid, shortid };