Merged PR 96: Implement SignalR integration for real-time notifications and messaging

Implement SignalR integration for real-time notifications and messaging
master
Tudor Stanciu 2025-06-01 18:38:38 +00:00
parent 3f04105641
commit b850997f62
65 changed files with 2132 additions and 55 deletions

View File

@ -1,7 +1,7 @@
<Project>
<Import Project="dependencies.props" />
<PropertyGroup>
<Version>1.4.1</Version>
<Version>1.4.2</Version>
<Authors>Tudor Stanciu</Authors>
<Company>STA</Company>
<PackageTags>NetworkResurrector</PackageTags>

View File

@ -230,15 +230,25 @@
<Content>
.NET 8 upgrade
◾ Upgrade all projects to .NET 8
◾ Upgrade packages to the latest versions
◾ Upgrade packages to the latest versions
</Content>
</Note>
<Note>
<Version>1.4.1</Version>
<Date>2025-04-27 01:50</Date>
<Content>
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.
</Content>
</Content>
</Note>
<Note>
<Version>1.4.2</Version>
<Date>2025-06-01 21:14</Date>
<Content>
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.
</Content>
</Note>
</ReleaseNotes>

View File

@ -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<WakeMachineHandler> logger, IResurrectorService resurrectorService, INetworkRepository repository, INotificationService notificationService)
public WakeMachineHandler(ILogger<WakeMachineHandler> logger, IResurrectorService resurrectorService, INetworkRepository repository, INotificationService notificationService, IMessageHubPublisher messageHubPublisher)
{
_logger=logger;
_resurrectorService=resurrectorService;
_repository=repository;
_notificationService=notificationService;
_messageHubPublisher=messageHubPublisher;
}
public async Task<MachineWaked> 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;
}
}

View File

@ -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
{

View File

@ -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>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage;
Task Send<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage;
Task SendToUser<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage;
}
}

View File

@ -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<NotificationResult> 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);
}

View File

@ -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<NotificationResult> 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)

View File

@ -1,4 +1,4 @@
namespace NetworkResurrector.Api.Domain.Models.Notifications
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
{
public record Notification
{

View File

@ -1,4 +1,4 @@
namespace NetworkResurrector.Api.Domain.Models.Notifications
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
{
public record NotificationContext
{

View File

@ -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
};
}
}
}

View File

@ -1,4 +1,4 @@
namespace NetworkResurrector.Api.Domain.Models.Notifications
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
{
public record NotificationTemplate
{

View File

@ -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; }
}
}

View File

@ -0,0 +1,22 @@
namespace NetworkResurrector.Api.Domain.Models.InternalNotifications
{
public interface IRealtimeMessage { }
public record RealtimeNotification<TPayload> where TPayload : IRealtimeMessage
{
public string Type { get; init; }
public TPayload Payload { get; init; }
public string SourceId { get; init; }
public static RealtimeNotification<TPayload> Create(TPayload payload, string sourceId)
{
var type = typeof(TPayload).FullName ?? "UnknownType";
return new RealtimeNotification<TPayload>
{
Type = type,
Payload = payload,
SourceId = sourceId
};
}
}
}

View File

@ -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<RealtimeNotificationsController> _logger;
public RealtimeNotificationsController(IMessageHubPublisher messageHubPublisher, ILogger<RealtimeNotificationsController> logger)
{
_messageHubPublisher = messageHubPublisher;
_logger = logger;
}
[HttpPost("broadcast")]
public async Task<IActionResult> Broadcast([FromBody] MessageModel message)
{
await _messageHubPublisher.Broadcast(message);
return Ok();
}
[HttpPost("message-to-client")]
public async Task<IActionResult> SendMessageToClient([FromBody] MessageModel message)
{
await _messageHubPublisher.Send(message);
return Ok();
}
[HttpPost("message-to-user")]
public async Task<IActionResult> SendMessageToUser([FromBody] MessageModel message)
{
await _messageHubPublisher.SendToUser(message);
return Ok();
}
}
public class MessageModel : IRealtimeMessage
{
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;
namespace NetworkResurrector.Api.Extensions
{
public static class HttpRequestExtensions
{
private const string SignalRConnectionIdHeader = "SignalR-ConnectionId";
/// <summary>
/// Gets the SignalR connection ID from the request headers if present
/// </summary>
/// <param name="request">The HTTP request</param>
/// <returns>The SignalR connection ID or null if not present</returns>
public static string GetSignalRConnectionId(this HttpRequest request)
{
if (request.Headers.TryGetValue(SignalRConnectionIdHeader, out var values))
{
return values.ToString();
}
return null;
}
}
}

View File

@ -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";
/// <summary>
/// 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.
/// </summary>
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();
});
}
/// <summary>
/// Gets the user identifier for the current connection
/// </summary>
/// <param name="context">HubCallerContext</param>
/// <returns>The user id or null if not authenticated</returns>
public static string GetUserId(this HubCallerContext context)
{
return context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
/// <summary>
/// Gets the username for the current connection
/// </summary>
/// <param name="context">HubCallerContext</param>
/// <returns>The username or null if not authenticated</returns>
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<IMessageHubPublisher, MessageHubPublisher>();
}
}
}

View File

@ -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<string[]>();
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<MessageHub>("/hubs/notifications");
});
app.ConfigureSwagger("NetworkResurrector API");

View File

@ -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<MessageHub> _logger;
public MessageHub(ILogger<MessageHub> 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);
}
}
}

View File

@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="$(MicrosoftExtensionsPackageVersion)" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="NBB.Messaging.Nats" Version="$(NBBPackageVersion)" />

View File

@ -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

View File

@ -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<MessageHub> _hubContext;
private readonly ILogger<MessageHubPublisher> _logger;
private readonly string _connectionId;
private readonly string _userId;
public MessageHubPublisher(IHubContext<MessageHub> hubContext, ILogger<MessageHubPublisher> 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;
}
/// <summary>
/// Broadcasts a message to all connected clients.
/// </summary>
public async Task Broadcast<T>(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<T>.Create(message, _connectionId);
_logger.LogInformation($"Broadcasting message of type: {notification.Type}.");
await _hubContext.Clients.All.SendAsync("ReceiveNotification", notification, cancellationToken);
}
/// <summary>
/// Sends a message to the connected client identified by the connection ID.
/// </summary>
public async Task Send<T>(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<T>.Create(message, _connectionId);
_logger.LogInformation($"Sending message of type: {notification.Type}.");
await _hubContext.Clients.Client(_connectionId).SendAsync("ReceiveNotification", notification, cancellationToken);
}
/// <summary>
/// Sends a message to the authenticated user identified by the user ID.
/// </summary>
public async Task SendToUser<T>(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<T>.Create(message, _connectionId);
_logger.LogInformation($"Sending message of type: {notification.Type}.");
await _hubContext.Clients.User(_userId).SendAsync("ReceiveNotification", notification, cancellationToken);
}
}
}

View File

@ -20,6 +20,9 @@
}
},
"AllowedHosts": "*",
"AllowedOrigins": [
"http://localhost:3000"
],
"Service": {
"Code": "NETWORK_RESURRECTOR_API"
},

View File

@ -1,5 +1,5 @@
VITE_APP_TUITIO_URL=https://<VITE_APP_TUITIO_URL>
VITE_APP_NETWORK_RESURRECTOR_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
VITE_APP_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
#600000 milliseconds = 10 minutes
VITE_APP_MACHINE_PING_INTERVAL=600000

View File

@ -1,6 +1,6 @@
VITE_APP_BASE_URL=
VITE_APP_TUITIO_URL=https://<VITE_APP_TUITIO_URL>
VITE_APP_NETWORK_RESURRECTOR_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
VITE_APP_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
#900000 milliseconds = 15 minutes
VITE_APP_MACHINE_PING_INTERVAL=900000

View File

@ -1,7 +1,7 @@
{
"bracketSpacing": true,
"arrowParens": "avoid",
"printWidth": 120,
"printWidth": 160,
"trailingComma": "none",
"singleQuote": false,
"endOfLine": "auto"

View File

@ -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",

View File

@ -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",

View File

@ -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": {

View File

@ -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": {

View File

@ -0,0 +1,21 @@
import { Tooltip, TooltipProps, styled } from "@mui/material";
const Hint = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)(({ 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;

View File

@ -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 };

View File

@ -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";

View File

@ -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 = () => {
<Route path="/machines" element={<NetworkContainer />} />
<Route path="/system" element={<SystemContainer />} />
<Route path="/settings" element={<SettingsContainer />} />
<Route path="/debugging/notifications" element={<NotificationDemo />} />
<Route path="/about" element={<AboutContainer />} />
<Route path="/*" element={<PageNotFound />} />
</Routes>

View File

@ -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<SideBarProps> = ({ open, onDrawerOpen, onDrawerClose })
const navigate = useNavigate();
const { t } = useTranslation();
menu.sort((a, b) => (a.order || 0) - (b.order || 0));
return (
<Drawer variant="permanent" open={open}>
<DrawerHeader>

View File

@ -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<TopBarProps> = ({ open, onDrawerOpen }) => {
</Typography>
<Box sx={{ flexGrow: 1 }} />
<Box sx={{ display: { xs: "none", md: "flex" }, gap: 1 }}>
<AppNotifications />
<SensitiveInfoToggle />
<LightDarkToggle />
<ProfileButton />

View File

@ -1,3 +1,4 @@
export * from "./menu";
import menu from "./menu";
export const drawerWidth = 240;
export { menu };

View File

@ -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: <Build />,
order: 0,
subMenus: [
{
code: "machines",
name: "Menu.Machines",
name: "Menu.Administration.Machines",
route: "/administration/machines",
icon: <Build />,
icon: <Devices />,
order: 0
},
{
code: "agents",
name: "Menu.Agents",
name: "Menu.Administration.Agents",
route: "/administration/agents",
icon: <Build />,
icon: <Stream />,
order: 1
}
]
@ -76,6 +80,23 @@ const menu: Menu = [
route: "/settings",
icon: <Settings />,
order: 1
},
{
code: "debugging",
name: "Menu.Debugging.Title",
route: "/debugging",
icon: <Adb />,
order: 2,
hidden: !isDevelopment,
subMenus: [
{
code: "notifications",
name: "Menu.Debugging.Notifications",
route: "/debugging/notifications",
icon: <Notifications />,
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;

View File

@ -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<Props> = ({ disabled }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const push = useCallback((notification: AppNotification) => {
setNotifications(prev => [...prev, notification]);
}, []);
const handleNotificationReceive = useCallback(
(notification: RealtimeNotification<AppNotificationPayload>) => {
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<AppNotificationPayload>(notificationTypes.APP_NOTIFICATION_RECEIVED, {
onNotification: handleNotificationReceive
});
useSubscription<EmailSentPayload>(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<HTMLButtonElement>) => {
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 (
<>
<Hint title="Notifications" arrow>
<IconButton aria-label={id} color="inherit" disabled={disabled} onClick={handleClick}>
<Badge
color="warning"
badgeContent={unread}
variant={variant}
overlap="circular"
anchorOrigin={{
vertical: "top",
horizontal: "right"
}}
>
<Notifications />
</Badge>
</IconButton>
</Hint>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left"
}}
slotProps={{ paper: { sx: { width: 450 } } }}
>
<PopoverContent notifications={notifications} onRemove={handleRemove} onMarkAllAsRead={handleMarkAllAsRead} />
</Popover>
</>
);
};
export default AppNotifications;

View File

@ -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<Props> = ({ notifications, onRemove, onMarkAllAsRead }) => {
const [viewUnreadOnly, setViewUnreadOnly] = useState<boolean>(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 (
<>
<Box
sx={{
alignItems: "center",
backgroundColor: "primary.main",
color: "primary.contrastText",
display: "flex",
justifyContent: "space-between",
px: 1,
py: 0.5
}}
>
<Typography color="inherit" variant="subtitle1">
Notifications
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "flex-end"
}}
>
<Tooltip title={viewUnreadOnly ? "View all" : "View unread only"}>
<span>
<IconButton onClick={() => setViewUnreadOnly(prev => !prev)} size="small" color="inherit" disabled={isEmpty}>
{viewUnreadOnly ? <EmailOutlined fontSize="small" /> : <MarkEmailUnreadOutlined fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Mark all as read">
<span>
<IconButton onClick={onMarkAllAsRead} size="small" color="inherit" disabled={isEmpty}>
<MarkEmailReadOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{isEmpty ? (
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ opacity: 0.6 }}>
There are no notifications
</Typography>
</Box>
) : (
<Box
sx={{
maxHeight: 400,
overflowY: "auto",
"&::-webkit-scrollbar": {
width: "8px"
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "rgba(0,0,0,0.2)",
borderRadius: "4px"
},
"&::-webkit-scrollbar-track": {
backgroundColor: "rgba(0,0,0,0.05)"
}
}}
>
<List disablePadding>
{filteredNotifications.map(notification => (
<ListItem
divider
key={notification.id}
sx={{
alignItems: "flex-start",
"&:hover": { backgroundColor: "action.hover" },
"& .MuiListItemSecondaryAction-root": { top: "24%" }
}}
secondaryAction={
<Tooltip title="Remove">
<IconButton edge="end" onClick={() => onRemove(notification.id)} size="small">
<CloseOutlined sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
}
>
{getNotificationContent(notification)}
</ListItem>
))}
</List>
</Box>
)}
</>
);
};
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 ? (
<CheckCircleOutlined color="success" />
) : isWarning ? (
<ReportOutlined color="warning" />
) : isError ? (
<ErrorOutlineOutlined color="error" />
) : (
<InfoOutlined color="info" />
);
return (
<>
<ListItemAvatar sx={{ mt: 0.5 }}>
<Avatar sx={{ bgcolor: "rgba(0, 0, 0, 0.1)", color: "primary.main" }}>{RowIcon}</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box
sx={{
alignItems: "flex-start",
display: "flex",
flexDirection: "column"
}}
>
<Typography sx={{ mb: 0.5, fontWeight: "bold" }} variant="body2">
{notification.author && <PrimarySpan>{notification.author} </PrimarySpan>}
{notification.title}
</Typography>
<Typography variant="body2">{notification.content}</Typography>
</Box>
}
secondary={
<Typography color="textSecondary" variant="caption">
{notification.moment.toLocaleString()}
</Typography>
}
sx={{ my: 0 }}
/>
</>
);
};
export default PopoverContent;

View File

@ -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;
};

View File

@ -1,6 +1,8 @@
import * as notificationTypes from "./notificationTypes";
const COLOR_SCHEME = {
LIGHT: "light",
DARK: "dark"
};
export { COLOR_SCHEME };
export { COLOR_SCHEME, notificationTypes };

View File

@ -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";

View File

@ -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<string, string> => {
const { token } = fetchTuitioData();
const headers: Record<string, string> = {
"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 (
<Box sx={{ p: 1 }}>
<Typography variant="h6" gutterBottom>
Test Notification API Endpoints
</Typography>
{/* Display connection ID info */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
{connectionId ? `Current connection ID: ${connectionId}` : "Not connected to SignalR hub. Connect first to receive notifications."}
</Typography>
</Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Broadcast Notification (API)
</Typography>
<TextField label="Title" fullWidth value={title} onChange={e => setTitle(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={message} onChange={e => setMessage(e.target.value)} margin="normal" />
<Button variant="contained" color="primary" startIcon={<SendIcon />} onClick={handleBroadcastNotification} sx={{ mt: 2 }}>
Broadcast via API
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User-specific Notification (API)
</Typography>
<TextField
label="User ID"
fullWidth
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="Enter connection ID to receive notification"
margin="normal"
/>
<TextField label="Title" fullWidth value={title} onChange={e => setTitle(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={message} onChange={e => setMessage(e.target.value)} margin="normal" />
<Button variant="contained" color="warning" startIcon={<SendIcon />} onClick={handleSendToUser} sx={{ mt: 2 }} disabled={!userId.trim()}>
Send to User via API
</Button>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Response status */}
{responseStatus && (
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 1,
bgcolor: responseStatus.success ? "success.light" : "error.light"
}}
>
<Typography variant="body2" color="textPrimary">
{responseStatus.message}
</Typography>
</Box>
)}
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary">
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.
</Typography>
</Box>
</Box>
);
};
export default NotificationApiTest;

View File

@ -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<TestNotification>({ 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<string[]>([]);
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 (
<>
<Box sx={{ p: 1 }}>
<Box sx={{ mb: 2, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="h6">SignalR Demo</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
<IconButton color="inherit" onClick={() => setOpen(true)}>
<Badge badgeContent={notifications.length} color="error">
<NotificationsIcon />
</Badge>
</IconButton>
<ConnectionStatus />
</Box>
</Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Send message
</Typography>
<TextField label="Message" fullWidth value={messageText} onChange={e => setMessageText(e.target.value)} margin="normal" />
<Button variant="contained" startIcon={<SendIcon />} onClick={handleSendTestMessage} sx={{ mt: 2 }}>
Send test message
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Send notification
</Typography>
<TextField label="Title" fullWidth value={notificationTitle} onChange={e => setNotificationTitle(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={notificationMessage} onChange={e => setNotificationMessage(e.target.value)} margin="normal" />
<Button variant="contained" color="secondary" startIcon={<SendIcon />} onClick={handleSendTestNotification} sx={{ mt: 2 }}>
Send notification
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Group messages
</Typography>
<TextField label="Group name" fullWidth value={groupName} onChange={e => setGroupName(e.target.value)} margin="normal" />
<Button variant="outlined" onClick={handleJoinGroup} sx={{ mt: 2, mr: 2 }}>
Join group
</Button>
{joinedGroups.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Joined groups:
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{joinedGroups.map((group, index) => (
<Chip key={index} label={group} />
))}
</Box>
</Box>
)}
<TextField label="Group message" fullWidth value={groupMessage} onChange={e => setGroupMessage(e.target.value)} margin="normal" />
<Button
variant="contained"
color="primary"
startIcon={<SendIcon />}
onClick={handleSendToGroup}
sx={{ mt: 2 }}
disabled={joinedGroups.length === 0}
>
Send to group
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Client-specific message
</Typography>
<TextField label="Connection ID" fullWidth value={clientConnectionId} onChange={e => setClientConnectionId(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={messageText} onChange={e => setMessageText(e.target.value)} margin="normal" />
<Button
variant="contained"
color="warning"
startIcon={<SendIcon />}
onClick={handleSendToClient}
sx={{ mt: 2 }}
disabled={!clientConnectionId.trim()}
>
Send to client
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User-specific notifications
</Typography>
<TextField label="User ID" fullWidth value={userId} onChange={e => setUserId(e.target.value)} margin="normal" />
<TextField label="Title" fullWidth value={userNotificationTitle} onChange={e => setUserNotificationTitle(e.target.value)} margin="normal" />
<TextField
label="Message"
fullWidth
value={userNotificationMessage}
onChange={e => setUserNotificationMessage(e.target.value)}
margin="normal"
/>
<Button variant="contained" color="warning" startIcon={<SendIcon />} onClick={handleSendToUser} sx={{ mt: 2 }} disabled={!userId.trim()}>
Send to user
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Received messages
</Typography>
<Box sx={{ maxHeight: "200px", overflowY: "auto", border: 1, borderColor: "divider", p: 1 }}>
{messages.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No messages received yet
</Typography>
) : (
<List dense>
{messages.map((message, index) => (
<ListItem key={index} divider={index < messages.length - 1}>
<Typography variant="body2">{message}</Typography>
</ListItem>
))}
</List>
)}
</Box>
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={handleClearMessages} sx={{ mt: 2 }}>
Clear messages
</Button>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Notification Drawer */}
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
<Box sx={{ width: 320, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">Notifications</Typography>
<IconButton onClick={() => setOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
{notifications.length === 0 ? (
<Paper elevation={0} sx={{ p: 2, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
No notifications
</Typography>
</Paper>
) : (
<List>
{notifications.map((notification, index) => (
<Paper key={index} elevation={1} sx={{ mb: 2, overflow: "hidden" }}>
<ListItem sx={{ bgcolor: "background.paper" }}>
<Box sx={{ width: "100%" }}>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{notification.type}
</Typography>
<Typography variant="body2">{JSON.stringify(notification.payload)}</Typography>
</Box>
</ListItem>
</Paper>
))}
</List>
)}
</Box>
</Drawer>
{/* Notification Snackbar */}
<Snackbar open={openSnackbar} autoHideDuration={6000} onClose={() => setOpenSnackbar(false)} anchorOrigin={{ vertical: "bottom", horizontal: "right" }}>
<Alert onClose={() => setOpenSnackbar(false)} severity="info" sx={{ width: "100%" }}>
<Typography variant="subtitle2">{currentNotification.title}</Typography>
<Typography variant="body2">{currentNotification.message}</Typography>
</Alert>
</Snackbar>
</Box>
<Divider sx={{ my: 2 }} />
<NotificationApiTest />
</>
);
};
export default NotificationDemo;

View File

@ -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 (
<Box sx={{ ml: 2, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Connection status:
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: isConnected ? "success.main" : "error.main"
}}
/>
<Typography variant="body2" sx={{ ml: 1 }}>
{isConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
{connectionId && (
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.75rem", mt: 0.5 }}>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Connection ID: {connectionId}
</Typography>
</Box>
)}
</Box>
);
};
export default ConnectionStatus;

View File

@ -0,0 +1,3 @@
import ConnectionStatus from "./ConnectionStatus";
export { ConnectionStatus };

View File

@ -4,3 +4,5 @@ import { useClipboard } from "./useClipboard";
import useApplicationTheme from "./useApplicationTheme";
export { useSensitiveInfo, usePermissions, useClipboard, useApplicationTheme };
export * from "units/notifications/hooks";

View File

@ -9,7 +9,7 @@ const useSensitiveInfo = () => {
const mask = text => {
if (!enabled) return text;
return obfuscate(text, "");
return obfuscate(text, "▪️");
};
const maskElements = list => {

View File

@ -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(
<CssBaseline />
<Suspense fallback={<div>Loading...</div>}>
<ToastProvider>
<AppRouter />
<RealtimeNotificationsProvider>
<AppRouter />
</RealtimeNotificationsProvider>
</ToastProvider>
</Suspense>
</ThemeProvider>

View File

@ -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 };

View File

@ -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<void>;
sendNotification: (notification: RealtimeNotification) => Promise<void>;
joinGroup: (groupName: string) => Promise<void>;
sendToGroup: (groupName: string, message: string) => Promise<void>;
sendToClient: (connectionId: string, message: string) => Promise<void>;
sendToUser: (userId: string, title: string, message: string) => Promise<void>;
}
const RealtimeNotificationsContext = createContext<RealtimeNotificationsContextData>({
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;

View File

@ -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<SignalRProviderProps> = ({ children }) => {
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(true);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [messages, setMessages] = useState<string[]>([]);
const [notifications, setNotifications] = useState<RealtimeNotification[]>([]);
const [connectionAttempts, setConnectionAttempts] = useState(0);
const listenersRef = useRef<RealtimeNotificationListeners>({});
const queuedNotificationsRef = useRef<RealtimeNotification[]>([]);
const safePublish: PublishFn = useCallback(
<T,>(event: RealtimeNotification<T>) => {
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(<T,>(notificationType: string, callback: (notification: RealtimeNotification<T>) => 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(
<T,>(event: RealtimeNotification<T>) => {
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 <ConnectingBox connectionAttempts={connectionAttempts} />;
}
// Show error if connection failed
if (connectionError) {
const retryLimitReached = connectionAttempts >= MAX_RETRY_ATTEMPTS;
return <ErrorBox message={connectionError} showActions={retryLimitReached} onRetry={handleRetryConnection} onAgreeAndContinue={handleAgreeAndContinue} />;
}
// Render children only when connected or user has chosen to continue without connection.
return <RealtimeNotificationsContext.Provider value={value}>{children}</RealtimeNotificationsContext.Provider>;
};
export default RealtimeNotificationsProvider;

View File

@ -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<Props> = ({ connectionAttempts }) => {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100vh"
}}
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
{connectionAttempts > 0
? `Establishing real-time connection... (Attempt ${connectionAttempts}/${MAX_RETRY_ATTEMPTS})`
: "Establishing real-time connection..."}
</Typography>
</Box>
);
};
export default ConnectingBox;

View File

@ -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<Props> = ({ message, showActions, onRetry, onAgreeAndContinue }) => {
return (
<Box sx={{ p: 3, textAlign: "center", color: "error.main" }}>
<Typography variant="h6">SignalR connection error</Typography>
<Typography variant="body1">{message}</Typography>
<Typography variant="body2" sx={{ mt: 2, mb: 3 }}>
The application might not function correctly without real-time updates.
</Typography>
{showActions && (
<>
<Button variant="contained" color="primary" onClick={onRetry} sx={{ mr: 2 }}>
Retry connection
</Button>
<Button variant="contained" color="primary" onClick={onAgreeAndContinue}>
Agree and continue
</Button>
</>
)}
</Box>
);
};
export default ErrorBox;

View File

@ -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 };

View File

@ -0,0 +1,4 @@
import useRealtimeNotifications from "./useRealtimeNotifications";
import useSubscription from "./useSubscription";
export { useRealtimeNotifications, useSubscription };

View File

@ -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;

View File

@ -0,0 +1,34 @@
import { useEffect, useMemo } from "react";
import { RealtimeNotification } from "../types";
import useRealtimeNotifications from "./useRealtimeNotifications";
interface SubscriptionOptions<T> {
skip?: boolean;
onNotification?: (event: RealtimeNotification<T>) => void;
}
const defaultOptions: Required<SubscriptionOptions<any>> = {
skip: false,
onNotification: () => {}
};
function useSubscription<T>(notificationType: string, options: SubscriptionOptions<T> = 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<T>(notificationType, opts.onNotification);
// Return an unsubscribe function to be called on component unmount
return () => {
unsubscribe();
};
}, [subscribe, notificationType, opts.skip, opts.onNotification]);
}
export default useSubscription;

View File

@ -0,0 +1,3 @@
import RealtimeNotificationsProvider from "./RealtimeNotificationsProvider";
export { RealtimeNotificationsProvider };

View File

@ -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
};

View File

@ -0,0 +1,20 @@
export type ConnectionStartOptions = {
onStart?: () => void;
onError?: (error: unknown) => void;
onSuccess?: () => void;
onEnd?: () => void;
};
//export type RealtimeNotificationPayload = Record<string, any>;
export type RealtimeNotification<T = any> = {
type: string;
payload: T;
sourceId?: string;
};
export type RealtimeNotificationListeners = { [key: string]: ((notification: RealtimeNotification) => void)[] };
export type UnsubscribeFn = () => void;
export type SubscribeFn = <T>(notificationType: string, callback: (notification: RealtimeNotification<T>) => void) => UnsubscribeFn;
export type PublishFn = <T>(notification: RealtimeNotification<T>) => void;

View File

@ -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;
};

View File

@ -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`;

View File

@ -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);
}

View File

@ -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 };