Merged PR 96: Implement SignalR integration for real-time notifications and messaging
Implement SignalR integration for real-time notifications and messagingmaster
parent
3f04105641
commit
b850997f62
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
namespace NetworkResurrector.Api.Domain.Models.Notifications
|
||||
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
|
||||
{
|
||||
public record Notification
|
||||
{
|
|
@ -1,4 +1,4 @@
|
|||
namespace NetworkResurrector.Api.Domain.Models.Notifications
|
||||
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
|
||||
{
|
||||
public record NotificationContext
|
||||
{
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
namespace NetworkResurrector.Api.Domain.Models.Notifications
|
||||
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
|
||||
{
|
||||
public record NotificationTemplate
|
||||
{
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,9 @@
|
|||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:3000"
|
||||
],
|
||||
"Service": {
|
||||
"Code": "NETWORK_RESURRECTOR_API"
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 120,
|
||||
"printWidth": 160,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"endOfLine": "auto"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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;
|
|
@ -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 };
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./menu";
|
||||
import menu from "./menu";
|
||||
|
||||
export const drawerWidth = 240;
|
||||
export { menu };
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -1,6 +1,8 @@
|
|||
import * as notificationTypes from "./notificationTypes";
|
||||
|
||||
const COLOR_SCHEME = {
|
||||
LIGHT: "light",
|
||||
DARK: "dark"
|
||||
};
|
||||
|
||||
export { COLOR_SCHEME };
|
||||
export { COLOR_SCHEME, notificationTypes };
|
||||
|
|
|
@ -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";
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
import ConnectionStatus from "./ConnectionStatus";
|
||||
|
||||
export { ConnectionStatus };
|
|
@ -4,3 +4,5 @@ import { useClipboard } from "./useClipboard";
|
|||
import useApplicationTheme from "./useApplicationTheme";
|
||||
|
||||
export { useSensitiveInfo, usePermissions, useClipboard, useApplicationTheme };
|
||||
|
||||
export * from "units/notifications/hooks";
|
|
@ -9,7 +9,7 @@ const useSensitiveInfo = () => {
|
|||
|
||||
const mask = text => {
|
||||
if (!enabled) return text;
|
||||
return obfuscate(text, "◾");
|
||||
return obfuscate(text, "▪️");
|
||||
};
|
||||
|
||||
const maskElements = list => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 };
|
|
@ -0,0 +1,4 @@
|
|||
import useRealtimeNotifications from "./useRealtimeNotifications";
|
||||
import useSubscription from "./useSubscription";
|
||||
|
||||
export { useRealtimeNotifications, useSubscription };
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
import RealtimeNotificationsProvider from "./RealtimeNotificationsProvider";
|
||||
|
||||
export { RealtimeNotificationsProvider };
|
|
@ -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
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 };
|
Loading…
Reference in New Issue