Compare commits
16 Commits
2ef7be585d
...
b850997f62
Author | SHA1 | Date |
---|---|---|
|
b850997f62 | |
|
3f04105641 | |
|
effe34cb20 | |
|
e39998a09d | |
|
1191e90db4 | |
|
4edd2c54d3 | |
|
8358fb6fac | |
|
640ae564ff | |
|
1a131a903b | |
|
984ee08a95 | |
|
5ac3ec74b6 | |
|
5fe5fb9f4f | |
|
5ab5d0777f | |
|
1adc2f6de1 | |
|
ce448c0f1b | |
|
a53e0fc57a |
|
@ -1,7 +1,7 @@
|
|||
<Project>
|
||||
<Import Project="dependencies.props" />
|
||||
<PropertyGroup>
|
||||
<Version>1.3.1</Version>
|
||||
<Version>1.4.2</Version>
|
||||
<Authors>Tudor Stanciu</Authors>
|
||||
<Company>STA</Company>
|
||||
<PackageTags>NetworkResurrector</PackageTags>
|
||||
|
|
|
@ -224,4 +224,31 @@
|
|||
• The progress bar is displayed at the top of the page and shows the loading status of the application.
|
||||
</Content>
|
||||
</Note>
|
||||
<Note>
|
||||
<Version>1.4.0</Version>
|
||||
<Date>2025-03-29 21:41</Date>
|
||||
<Content>
|
||||
.NET 8 upgrade
|
||||
◾ Upgrade all projects to .NET 8
|
||||
◾ 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
|
||||
◾ 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>
|
||||
</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>
|
|
@ -4,16 +4,16 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
|
||||
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ProjectGuid>c8c4ca6f-39e2-46fe-89e2-0a81d2f4161e</ProjectGuid>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
{
|
||||
"Urls": "http://*:5068",
|
||||
"ConnectionStrings": {
|
||||
"DatabaseConnection": "Server=#########;Database=#########;User Id=#########;Password=#########;MultipleActiveResultSets=true"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information"
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
|||
using NetworkResurrector.Server.Wrapper.Services;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -35,12 +36,12 @@ namespace NetworkResurrector.Api.Application.Queries
|
|||
public async Task<Model> Handle(Query request, CancellationToken cancellationToken)
|
||||
{
|
||||
var apiVersion = GetApiVersion();
|
||||
var serverVersion = await GetServerVersion();
|
||||
var (version, lastUpdateDate) = await GetServerVersion();
|
||||
|
||||
var result = new Model
|
||||
{
|
||||
Api = apiVersion,
|
||||
Server = serverVersion
|
||||
Server = new ServiceVersion(FormatVersion(version), lastUpdateDate)
|
||||
};
|
||||
|
||||
return result;
|
||||
|
@ -52,7 +53,7 @@ namespace NetworkResurrector.Api.Application.Queries
|
|||
var appDate = Environment.GetEnvironmentVariable("APP_DATE");
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
version = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
|
||||
version = Assembly.GetEntryAssembly().GetName().Version.ToString();
|
||||
|
||||
if (!DateTime.TryParse(appDate, out var lastReleaseDate))
|
||||
{
|
||||
|
@ -60,7 +61,7 @@ namespace NetworkResurrector.Api.Application.Queries
|
|||
lastReleaseDate = File.GetLastWriteTime(location);
|
||||
}
|
||||
|
||||
var result = new ServiceVersion(version, lastReleaseDate);
|
||||
var result = new ServiceVersion(FormatVersion(version), lastReleaseDate);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
@ -77,6 +78,17 @@ namespace NetworkResurrector.Api.Application.Queries
|
|||
}
|
||||
return new ServiceVersion("0.0.0", DateTime.MinValue);
|
||||
}
|
||||
|
||||
private string FormatVersion(string version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return string.Empty;
|
||||
var parts = version.Split('.');
|
||||
if (parts.Length < 3)
|
||||
return version;
|
||||
var shortVersion = string.Join('.', parts.Take(3));
|
||||
return shortVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +13,7 @@ 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;
|
||||
|
||||
|
@ -25,6 +26,9 @@ namespace NetworkResurrector.Api.Extensions
|
|||
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)
|
||||
public static void Configure(this IApplicationBuilder app, IConfiguration configuration)
|
||||
{
|
||||
// global cors policy
|
||||
app.UseCors(x => x.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
|
||||
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"
|
||||
},
|
||||
|
|
|
@ -24,7 +24,7 @@ namespace NetworkResurrector.Server.Application.Queries
|
|||
var appDate = Environment.GetEnvironmentVariable("APP_DATE");
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
version = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
|
||||
version = Assembly.GetEntryAssembly().GetName().Version.ToString();
|
||||
|
||||
if (!DateTime.TryParse(appDate, out var lastReleaseDate))
|
||||
{
|
||||
|
|
|
@ -4,16 +4,16 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
|
||||
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ProjectGuid>f6600491-5d79-4548-8745-59d9d337d3db</ProjectGuid>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
|
|
@ -4,4 +4,3 @@ build
|
|||
__mocks__
|
||||
.vscode
|
||||
helm
|
||||
private
|
|
@ -1,8 +1,11 @@
|
|||
REACT_APP_TUITIO_URL=http://#######
|
||||
REACT_APP_NETWORK_RESURRECTOR_API_URL=http://#######
|
||||
VITE_APP_TUITIO_URL=https://<VITE_APP_TUITIO_URL>
|
||||
VITE_APP_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
|
||||
|
||||
#600000 milliseconds = 10 minutes
|
||||
REACT_APP_MACHINE_PING_INTERVAL=600000
|
||||
VITE_APP_MACHINE_PING_INTERVAL=600000
|
||||
|
||||
#300000 milliseconds = 5 minutes
|
||||
REACT_APP_MACHINE_STARTING_TIME=300000
|
||||
VITE_APP_MACHINE_STARTING_TIME=300000
|
||||
|
||||
# VITE_APP_BASE_URL=/network-resurrector/
|
||||
VITE_APP_BASE_URL=
|
|
@ -1,9 +1,9 @@
|
|||
PUBLIC_URL=
|
||||
REACT_APP_TUITIO_URL=https://#######
|
||||
REACT_APP_NETWORK_RESURRECTOR_API_URL=https://#######
|
||||
VITE_APP_BASE_URL=
|
||||
VITE_APP_TUITIO_URL=https://<VITE_APP_TUITIO_URL>
|
||||
VITE_APP_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
|
||||
|
||||
#900000 milliseconds = 15 minutes
|
||||
REACT_APP_MACHINE_PING_INTERVAL=900000
|
||||
VITE_APP_MACHINE_PING_INTERVAL=900000
|
||||
|
||||
#300000 milliseconds = 5 minutes
|
||||
REACT_APP_MACHINE_STARTING_TIME=300000
|
||||
VITE_APP_MACHINE_STARTING_TIME=300000
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"prettier",
|
||||
"plugin:prettier/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"],
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-debugger": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/public"],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"globals": {
|
||||
"JSX": true
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
# production
|
||||
/build
|
||||
/build2
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 120,
|
||||
"printWidth": 160,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"endOfLine": "auto"
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
const getCacheIdentifier = require("react-dev-utils/getCacheIdentifier");
|
||||
|
||||
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
|
||||
|
||||
module.exports = function override(config, webpackEnv) {
|
||||
console.log("overriding webpack config...");
|
||||
|
||||
const isEnvDevelopment = webpackEnv === "development";
|
||||
const isEnvProduction = webpackEnv === "production";
|
||||
const loaders = config.module.rules[1].oneOf;
|
||||
|
||||
loaders.splice(loaders.length - 1, 0, {
|
||||
test: /\.(js|mjs|cjs)$/,
|
||||
exclude: /@babel(?:\/|\\{1,2})runtime/,
|
||||
loader: require.resolve("babel-loader"),
|
||||
options: {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
compact: false,
|
||||
presets: [[require.resolve("babel-preset-react-app/dependencies"), { helpers: true }]],
|
||||
cacheDirectory: true,
|
||||
// See #6846 for context on why cacheCompression is disabled
|
||||
cacheCompression: false,
|
||||
// @remove-on-eject-begin
|
||||
cacheIdentifier: getCacheIdentifier(isEnvProduction ? "production" : isEnvDevelopment && "development", [
|
||||
"babel-plugin-named-asset-import",
|
||||
"babel-preset-react-app",
|
||||
"react-dev-utils",
|
||||
"react-scripts"
|
||||
]),
|
||||
// @remove-on-eject-end
|
||||
// Babel sourcemaps are needed for debugging into node_modules
|
||||
// code. Without the options below, debuggers like VSCode
|
||||
// show incorrect code and set breakpoints on the wrong lines.
|
||||
sourceMaps: shouldUseSourceMap,
|
||||
inputSourceMap: shouldUseSourceMap
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
# BUILD ENVIRONMENT
|
||||
FROM node:16-slim AS builder
|
||||
FROM node:23-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ARG APP_SUBFOLDER=""
|
||||
|
@ -12,24 +12,26 @@ RUN rm -f .npmrc
|
|||
COPY . ./
|
||||
|
||||
# build the react app
|
||||
RUN if [ -z "$APP_SUBFOLDER" ]; then npm run build; else PUBLIC_URL=/${APP_SUBFOLDER}/ npm run build; fi
|
||||
RUN if [ -z "$APP_SUBFOLDER" ]; then npm run build; else VITE_APP_BASE_URL=/${APP_SUBFOLDER}/ npm run build; fi
|
||||
|
||||
# PRODUCTION ENVIRONMENT
|
||||
FROM node:16-slim
|
||||
FROM node:23-slim
|
||||
|
||||
ARG APP_SUBFOLDER=""
|
||||
|
||||
RUN printf '\n\n- Copy application files\n'
|
||||
COPY --from=builder /app/build ./application/${APP_SUBFOLDER}
|
||||
COPY --from=builder /app/build/index.html ./application/
|
||||
COPY --from=builder /app/setenv.js ./application/setenv.js
|
||||
COPY --from=builder /app/runtimeSetup.js ./application/runtimeSetup.js
|
||||
|
||||
#install static server
|
||||
RUN npm install -g serve
|
||||
|
||||
# environment variables
|
||||
ENV AUTHOR="Tudor Stanciu"
|
||||
ENV PUBLIC_URL=/${APP_SUBFOLDER}/
|
||||
ENV APP_NAME="Network resurrector UI"
|
||||
ENV VITE_APP_BASE_URL=/${APP_SUBFOLDER}/
|
||||
|
||||
ARG APP_VERSION=0.0.0
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
|
@ -41,4 +43,4 @@ WORKDIR /
|
|||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["sh", "-c", "node application/setenv.js && serve -s application -p 80"]
|
||||
CMD ["sh", "-c", "node application/runtimeSetup.js && serve -s application -l 80 --no-clipboard"]
|
|
@ -0,0 +1,68 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["node_modules", "dist", "build", "**/public", "runtimeSetup.js"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect"
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
react,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
prettier
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs["jsx-runtime"].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true
|
||||
}
|
||||
],
|
||||
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-debugger": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"prefer-const": "warn",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-undef": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
rules: {
|
||||
"no-undef": "warn",
|
||||
"no-unused-expressions": "off"
|
||||
}
|
||||
}
|
||||
);
|
|
@ -0,0 +1,35 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.jpg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<script src="/env.js"></script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<title>Network resurrector</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"name": "network-resurrector-frontend",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.2",
|
||||
"description": "Frontend component of Network resurrector system",
|
||||
"type": "module",
|
||||
"author": {
|
||||
"name": "Tudor Stanciu",
|
||||
"email": "tudor.stanciu94@gmail.com",
|
||||
|
@ -13,54 +14,60 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@flare/js-utils": "^1.1.0",
|
||||
"@flare/tuitio-client-react": "^1.2.10",
|
||||
"@mui/icons-material": "^5.14.16",
|
||||
"@mui/lab": "^5.0.0-alpha.169",
|
||||
"@mui/material": "^5.14.16",
|
||||
"axios": "^1.6.8",
|
||||
"i18next": "^22.4.15",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next-http-backend": "^2.2.0",
|
||||
"@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",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"axios": "^1.9.0",
|
||||
"i18next": "^25.0.1",
|
||||
"i18next-browser-languagedetector": "^8.0.5",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-lazylog": "^4.5.3",
|
||||
"react-router-dom": "^6.10.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"react-toastify": "^11.0.5",
|
||||
"react-world-flags": "^1.6.0",
|
||||
"swr": "^2.2.5"
|
||||
"swr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/react": "^18.2.33",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@types/react-world-flags": "^1.4.5",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^2.8.4",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"typescript": "^4.9.5"
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.15.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-world-flags": "^1.6.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.31.0",
|
||||
"vite": "^6.3.3",
|
||||
"vite-plugin-checker": "^0.9.1",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-app-rewired eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"start": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "jest"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.jpg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
/>
|
||||
<script src="%PUBLIC_URL%/env.js"></script>
|
||||
<title>Network resurrector</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
|
@ -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": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"name": "Vite React App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const prefix = "VITE_APP_";
|
||||
const APP_DIR = "./application";
|
||||
|
||||
function generateScriptContent() {
|
||||
const prefixRegex = new RegExp(`^${prefix}`);
|
||||
const env = process.env;
|
||||
const config = Object.keys(env)
|
||||
.filter(key => prefixRegex.test(key))
|
||||
.reduce((c, key) => Object.assign({}, c, { [key]: env[key] }), {});
|
||||
|
||||
return `window.env=${JSON.stringify(config)};`;
|
||||
}
|
||||
|
||||
function getSha256Hash(input) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(input);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function updateIndexHtml(envFileName, basePath) {
|
||||
const indexPath = path.join(APP_DIR, "index.html");
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.error(`Error: ${indexPath} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
let indexContent = fs.readFileSync(indexPath, "utf8");
|
||||
|
||||
// Replace base path placeholder with actual value
|
||||
// indexContent = indexContent.replace(new RegExp(RUNTIME_BASE_URL_PLACEHOLDER, "g"), basePath || "/");
|
||||
|
||||
// Replace any existing env script with the new one
|
||||
const envScriptRegex = /<script src="[^"]*env(\.\w+)?\.js"[^>]*><\/script>/;
|
||||
const scriptSrc = basePath ? `${basePath.endsWith("/") ? basePath : basePath + "/"}${envFileName}` : envFileName;
|
||||
const newEnvScript = `<script src="${scriptSrc}"></script>`;
|
||||
|
||||
if (envScriptRegex.test(indexContent)) {
|
||||
indexContent = indexContent.replace(envScriptRegex, newEnvScript);
|
||||
} else {
|
||||
// If no existing env script, add it before the first script tag
|
||||
const insertPoint = indexContent.indexOf("<script");
|
||||
if (insertPoint !== -1) {
|
||||
indexContent =
|
||||
indexContent.substring(0, insertPoint) + newEnvScript + "\n " + indexContent.substring(insertPoint);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexPath, indexContent, "utf8");
|
||||
console.log(`Updated ${indexPath} with base path: ${basePath || "/"} and env script: ${scriptSrc}`);
|
||||
}
|
||||
|
||||
function cleanupOldEnvFiles(newEnvFileName, envJsDir) {
|
||||
// Find all env*.js files and delete them except the new one
|
||||
const files = fs.readdirSync(envJsDir);
|
||||
const envFiles = files.filter(file => /^env(\.\w+)?\.js$/.test(file) && file !== newEnvFileName);
|
||||
|
||||
for (const file of envFiles) {
|
||||
const filePath = path.join(envJsDir, file);
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`Removed old env file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("Setting environment variables...");
|
||||
const basePath = process.env.VITE_APP_BASE_URL || "/";
|
||||
|
||||
// Generate env script content
|
||||
const scriptContent = generateScriptContent();
|
||||
|
||||
// Compute hash for cache busting
|
||||
const hash = getSha256Hash(scriptContent);
|
||||
const fragment = hash.substring(0, 8);
|
||||
const envJsDir = path.join(APP_DIR, basePath);
|
||||
const envFileName = `env.${fragment}.js`;
|
||||
const envFilePath = path.join(envJsDir, envFileName);
|
||||
|
||||
// Ensure build directory exists
|
||||
if (!fs.existsSync(APP_DIR)) {
|
||||
console.log(`Creating build directory: ${APP_DIR}`);
|
||||
fs.mkdirSync(APP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Write new env.js file
|
||||
fs.writeFileSync(envFilePath, scriptContent, "utf8");
|
||||
console.log(`Updated ${envFilePath} with ${prefix}* environment variables`);
|
||||
|
||||
// Clean up old env.js files
|
||||
cleanupOldEnvFiles(envFileName, envJsDir);
|
||||
|
||||
// Get base path from environment and update index.html
|
||||
updateIndexHtml(envFileName, basePath);
|
||||
}
|
||||
|
||||
main();
|
|
@ -1,29 +0,0 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const prefix = "REACT_APP_";
|
||||
const publicUrl = process.env.PUBLIC_URL || "";
|
||||
const scriptPath = path.join("./application", publicUrl, "env.js");
|
||||
|
||||
function generateScriptContent() {
|
||||
const prefixRegex = new RegExp(`^${prefix}`);
|
||||
const env = process.env;
|
||||
const config = Object.keys(env)
|
||||
.filter(key => prefixRegex.test(key))
|
||||
.reduce((c, key) => Object.assign({}, c, { [key]: env[key] }), {});
|
||||
return `window.env=${JSON.stringify(config)};`;
|
||||
}
|
||||
|
||||
function saveScriptContent(scriptContents) {
|
||||
fs.writeFile(scriptPath, scriptContents, "utf8", function (err) {
|
||||
if (err) throw err;
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Setting environment variables...");
|
||||
const scriptContent = generateScriptContent();
|
||||
saveScriptContent(scriptContent);
|
||||
console.log(
|
||||
`Updated ${scriptPath} with ${prefix}* environment variables: ${scriptContent}.`
|
||||
);
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { UserPermissionsProvider, SensitiveInfoProvider } from "../providers";
|
||||
import { SensitiveInfoProvider } from "../providers";
|
||||
import { UserPermissionsProvider } from "../units/permissions";
|
||||
import AppLayout from "./layout/AppLayout";
|
||||
|
||||
const App = () => {
|
|
@ -3,8 +3,9 @@ import App from "./App";
|
|||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { useTuitioToken } from "@flare/tuitio-client-react";
|
||||
import LoginContainer from "../features/login/components/LoginContainer";
|
||||
import env from "utils/env";
|
||||
|
||||
const PrivateRoute = ({ children }: { children: JSX.Element }): JSX.Element => {
|
||||
const PrivateRoute = ({ children }: { children: React.ReactElement }): React.ReactElement => {
|
||||
const { valid } = useTuitioToken();
|
||||
const location = useLocation();
|
||||
return valid ? (
|
||||
|
@ -20,7 +21,7 @@ const PrivateRoute = ({ children }: { children: JSX.Element }): JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
const PublicRoute = ({ children }: { children: JSX.Element }): JSX.Element => {
|
||||
const PublicRoute = ({ children }: { children: React.ReactElement }): React.ReactElement => {
|
||||
const location = useLocation();
|
||||
const { valid } = useTuitioToken();
|
||||
const to = useMemo(() => {
|
||||
|
@ -41,9 +42,16 @@ const PublicRoute = ({ children }: { children: JSX.Element }): JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
const baseName = (() => {
|
||||
if (!env.VITE_APP_BASE_URL) return "";
|
||||
let baseUrl = env.VITE_APP_BASE_URL.endsWith("/") ? env.VITE_APP_BASE_URL.slice(0, -1) : env.VITE_APP_BASE_URL;
|
||||
baseUrl = baseUrl.startsWith("/") ? baseUrl : `/${baseUrl}`;
|
||||
return baseUrl;
|
||||
})();
|
||||
|
||||
const AppRouter: React.FC = () => {
|
||||
return (
|
||||
<BrowserRouter basename={process.env.PUBLIC_URL || ""}>
|
||||
<BrowserRouter basename={baseName}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
<Route
|
||||
|
|
|
@ -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,4 @@
|
|||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Typography, Box } from "@mui/material";
|
||||
|
||||
const styles = {
|
||||
|
@ -20,12 +19,18 @@ const styles = {
|
|||
}
|
||||
};
|
||||
|
||||
const PageTitle = ({ text, toolBar, navigation }) => {
|
||||
type PageTitleProps = {
|
||||
text: string;
|
||||
toolBar?: React.ReactNode;
|
||||
navigation?: React.ReactNode;
|
||||
};
|
||||
|
||||
const PageTitle: React.FC<PageTitleProps> = ({ text, toolBar, navigation }) => {
|
||||
return (
|
||||
<Box sx={styles.box}>
|
||||
{navigation && navigation}
|
||||
<Box sx={styles.title}>
|
||||
<Typography sx={styles.titleText} variant="h3" size="sm">
|
||||
<Typography sx={styles.titleText} variant="h3">
|
||||
{text}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
@ -34,10 +39,4 @@ const PageTitle = ({ text, toolBar, navigation }) => {
|
|||
);
|
||||
};
|
||||
|
||||
PageTitle.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
toolBar: PropTypes.node,
|
||||
navigation: PropTypes.node
|
||||
};
|
||||
|
||||
export default PageTitle;
|
|
@ -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 };
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Icon as MuiIcon, IconProps } from "@mui/material";
|
|||
|
||||
interface Props extends IconProps {
|
||||
code?: string | null;
|
||||
fallback?: JSX.Element;
|
||||
fallback?: React.ReactElement;
|
||||
}
|
||||
|
||||
const DynamicIcon: React.FC<Props> = ({ code, fallback, ...res }) => {
|
||||
|
|
|
@ -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,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { IconButton } from "@mui/material";
|
||||
import { Brightness2 as MoonIcon, WbSunny as SunIcon } from "@mui/icons-material";
|
||||
import { useApplicationTheme } from "../../providers/ThemeProvider";
|
||||
import { useApplicationTheme } from "../../hooks";
|
||||
|
||||
const LightDarkToggle = () => {
|
||||
const { isDark, onDarkModeChanged } = useApplicationTheme();
|
|
@ -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,13 +1,17 @@
|
|||
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;
|
||||
name: string;
|
||||
route: string;
|
||||
icon: JSX.Element;
|
||||
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;
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import * as notificationTypes from "./notificationTypes";
|
||||
|
||||
const COLOR_SCHEME = {
|
||||
LIGHT: "light",
|
||||
DARK: "dark"
|
||||
};
|
||||
|
||||
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";
|
|
@ -7,13 +7,13 @@ const ReleaseNoteSummary = ({ releaseNote, collapsed }) => {
|
|||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<Grid container flexGrow={1}>
|
||||
<Grid size={{ xs: 12, sm: 2 }}>
|
||||
<Typography variant={collapsed ? "subtitle2" : "h6"}>
|
||||
{`${t("About.ReleaseNotes.Version")}: ${releaseNote.version}`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={2} md={collapsed ? 2 : 4}>
|
||||
<Grid size={{ xs: 6, sm: 2, md: collapsed ? 2 : 4 }}>
|
||||
<Typography variant={collapsed ? "subtitle2" : "h6"}>
|
||||
{`${t("About.ReleaseNotes.Date")}: ${t("DATE_FORMAT", {
|
||||
date: { value: releaseNote.date, format: "DD-MM-YYYY HH:mm" }
|
||||
|
@ -21,7 +21,7 @@ const ReleaseNoteSummary = ({ releaseNote, collapsed }) => {
|
|||
</Typography>
|
||||
</Grid>
|
||||
{collapsed && (
|
||||
<Grid item xs={12} sm={8} md={8}>
|
||||
<Grid size={{ xs: 12, sm: 8, md: 6 }}>
|
||||
<Typography variant="body2">{releaseNote.notes[0]}</Typography>
|
||||
</Grid>
|
||||
)}
|
|
@ -43,7 +43,7 @@ const SystemVersionComponent: React.FC<Props> = ({ data }) => {
|
|||
|
||||
const frontend = t("DATE_FORMAT", {
|
||||
date: {
|
||||
value: process.env.APP_DATE ?? new Date(),
|
||||
value: import.meta.env.APP_DATE ?? new Date(),
|
||||
format
|
||||
}
|
||||
});
|
||||
|
@ -109,7 +109,7 @@ const SystemVersionComponent: React.FC<Props> = ({ data }) => {
|
|||
primary={
|
||||
<VersionLabel>
|
||||
{t("About.System.Version.Frontend", {
|
||||
version: process.env.APP_VERSION ?? packageData.version
|
||||
version: import.meta.env.APP_VERSION ?? packageData.version
|
||||
})}
|
||||
</VersionLabel>
|
||||
}
|
||||
|
|
|
@ -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 };
|
|
@ -58,7 +58,7 @@ type GridCellProps = {
|
|||
const GridCell: React.FC<GridCellProps> = ({ label, value }) => {
|
||||
const { mask } = useSensitiveInfo();
|
||||
return (
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 3 }}>
|
||||
<DataLabel label={label} data={mask(value)} />
|
||||
</Grid>
|
||||
);
|
||||
|
@ -78,12 +78,13 @@ const MachineAccordion: React.FC<Props> = ({ machine, actions, logs, addLog }) =
|
|||
<AccordionSummary aria-controls={`machine-${machine.machineId}-summary`} id={`machine-${machine.machineId}`}>
|
||||
<Grid
|
||||
container
|
||||
flex={1}
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Grid item xs={11}>
|
||||
<Grid size={11}>
|
||||
<Grid container>
|
||||
<GridCell label={t("Machine.FullName")} value={machine.fullMachineName} />
|
||||
<GridCell label={t("Machine.Name")} value={machine.machineName} />
|
||||
|
@ -91,7 +92,7 @@ const MachineAccordion: React.FC<Props> = ({ machine, actions, logs, addLog }) =
|
|||
<GridCell label={t("Machine.MAC")} value={machine.macAddress} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={1} style={{ textAlign: "right" }}>
|
||||
<Grid size={1} style={{ textAlign: "right" }}>
|
||||
<ActionsGroup machine={machine} actions={actions} addLog={addLog} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import MachineTableRow from "./MachineTableRow";
|
||||
import MachineAccordion from "./MachineAccordion";
|
||||
import { ViewModes } from "./ViewModeSelection";
|
||||
import { ViewModes } from "../constants";
|
||||
import { blip } from "../../../utils";
|
||||
import { LastPage, RotateLeft, Launch, Stop } from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
|
@ -3,7 +3,8 @@ import { NetworkStateContext, NetworkDispatchContext } from "../../network/state
|
|||
import MachinesListComponent from "./MachinesListComponent";
|
||||
import PageTitle from "../../../components/common/PageTitle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ViewModeSelection, { ViewModes } from "./ViewModeSelection";
|
||||
import ViewModeSelection from "./ViewModeSelection";
|
||||
import { ViewModes } from "../constants";
|
||||
import { endpoints } from "../../../utils/api";
|
||||
import { useSWR, fetcher } from "units/swr";
|
||||
import { blip } from "utils";
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from
|
|||
import Paper from "@mui/material/Paper";
|
||||
import MachineContainer from "./MachineContainer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ViewModes } from "./ViewModeSelection";
|
||||
import { ViewModes } from "../constants";
|
||||
|
||||
const MachinesList = ({ machines, viewMode }) => {
|
||||
return (
|
|
@ -5,11 +5,7 @@ import ViewListIcon from "@mui/icons-material/ViewList";
|
|||
import { ToggleButtonGroup, ToggleButton } from "@mui/material";
|
||||
import { Tooltip } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const ViewModes = {
|
||||
TABLE: "table",
|
||||
ACCORDION: "accordion"
|
||||
};
|
||||
import { ViewModes } from "../constants";
|
||||
|
||||
const ViewModeSelection = ({ initialMode, callback }) => {
|
||||
const [state, setState] = useState({
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import PropTypes from "prop-types";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
|
||||
const ActionButton = React.forwardRef(props => {
|
||||
const ActionButton = React.forwardRef((props, ref) => {
|
||||
const { action, machine, callback, disabled } = props;
|
||||
const id = `machine-item-${machine.machineId}-${action.code}`;
|
||||
const handleActionClick = event => {
|
||||
|
@ -21,6 +21,7 @@ const ActionButton = React.forwardRef(props => {
|
|||
onFocus={event => event.stopPropagation()}
|
||||
onClick={handleActionClick}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
<action.icon />
|
||||
</IconButton>
|
||||
|
@ -36,7 +37,8 @@ ActionButton.propTypes = {
|
|||
action: PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
tooltip: PropTypes.string.isRequired,
|
||||
effect: PropTypes.func.isRequired
|
||||
effect: PropTypes.func.isRequired,
|
||||
icon: PropTypes.elementType.isRequired
|
||||
}).isRequired,
|
||||
callback: PropTypes.func,
|
||||
disabled: PropTypes.bool
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue