Compare commits

...

16 Commits

Author SHA1 Message Date
Tudor Stanciu b850997f62 Merged PR 96: Implement SignalR integration for real-time notifications and messaging
Implement SignalR integration for real-time notifications and messaging
2025-06-01 18:38:38 +00:00
Tudor Stanciu 3f04105641 Fix typo in ReleaseNotes for version 1.4.1 migration description 2025-05-13 02:11:20 +03:00
Tudor Stanciu effe34cb20 Refactor AppRouter to use dynamic base name from environment variable 2025-05-13 02:04:44 +03:00
Tudor Stanciu e39998a09d Refactor build process and runtime setup for dynamic base path handling 2025-05-13 01:46:10 +03:00
Tudor Stanciu 1191e90db4 Update app name in manifest.json to reflect Vite integration 2025-05-04 14:48:17 +03:00
Tudor Stanciu 4edd2c54d3 Update .env to use secure HTTPS URLs for API endpoints 2025-05-04 11:35:31 +03:00
Tudor Stanciu 8358fb6fac Update Dockerfile to modify serve command options for production environment 2025-05-04 11:31:21 +03:00
Tudor Stanciu 640ae564ff Fix base path handling in env script injection for index.html 2025-05-02 02:31:59 +03:00
Tudor Stanciu 1a131a903b Merged PR 95: Base path fix after Vite migration 2025-05-01 21:51:51 +00:00
Tudor Stanciu 984ee08a95 Update Dockerfile to use Node.js 23-slim for both build and production environments 2025-04-28 00:00:12 +03:00
Tudor Stanciu 5ac3ec74b6 Bump version to 1.4.1 in project files 2025-04-27 03:20:22 +03:00
Tudor Stanciu 5fe5fb9f4f Refactor version retrieval to use Assembly.GetName().Version and add version formatting in GetSystemVersion and GetServiceVersion queries 2025-04-27 03:17:17 +03:00
Tudor Stanciu 5ab5d0777f Merged PR 93: Migrate from Create React App to Vite
- Update dependencies and refactor import for localStorage utility
- Refactor component prop types from JSX.Element to React.ReactElement for consistency
- refactor: migrate from Create React App to Vite
2025-04-27 00:15:51 +00:00
Tudor Stanciu 1adc2f6de1 agent config 2025-03-29 23:32:21 +02:00
Tudor Stanciu ce448c0f1b publish profiles update 2025-03-29 22:50:44 +02:00
Tudor Stanciu a53e0fc57a [1.4.0] release notes 2025-03-29 21:42:19 +02:00
155 changed files with 5832 additions and 33682 deletions

View File

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

View File

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

View File

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

View File

@ -1,8 +1,5 @@
{
"Urls": "http://*:5068",
"ConnectionStrings": {
"DatabaseConnection": "Server=#########;Database=#########;User Id=#########;Password=#########;MultipleActiveResultSets=true"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information"

View File

@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using NetworkResurrector.Api.Application.Extensions;
using NetworkResurrector.Api.Application.Services.Abstractions;
using NetworkResurrector.Api.Domain.Constants;
using NetworkResurrector.Api.Domain.Models.InternalNotifications;
using NetworkResurrector.Api.Domain.Repositories;
using NetworkResurrector.Api.PublishedLanguage.Commands;
using NetworkResurrector.Api.PublishedLanguage.Events;
@ -19,13 +20,15 @@ namespace NetworkResurrector.Api.Application.CommandHandlers
private readonly IResurrectorService _resurrectorService;
private readonly INetworkRepository _repository;
private readonly INotificationService _notificationService;
private readonly IMessageHubPublisher _messageHubPublisher;
public WakeMachineHandler(ILogger<WakeMachineHandler> logger, IResurrectorService resurrectorService, INetworkRepository repository, INotificationService notificationService)
public WakeMachineHandler(ILogger<WakeMachineHandler> logger, IResurrectorService resurrectorService, INetworkRepository repository, INotificationService notificationService, IMessageHubPublisher messageHubPublisher)
{
_logger=logger;
_resurrectorService=resurrectorService;
_repository=repository;
_notificationService=notificationService;
_messageHubPublisher=messageHubPublisher;
}
public async Task<MachineWaked> Handle(WakeMachine command, CancellationToken cancellationToken)
@ -51,7 +54,17 @@ namespace NetworkResurrector.Api.Application.CommandHandlers
}
var notificationContext = machine.ToNotificationContext(result.Status, performer);
await _notificationService.Notify(NotificationType.Wake, notificationContext, cancellationToken);
var notificationResult = await _notificationService.Notify(NotificationType.Wake, notificationContext, cancellationToken);
var to = string.Join(", ", notificationResult.To);
var ev = new EmailSent()
{
To = to,
MachineName = machine.FullMachineName,
Status = result.Status
};
await _messageHubPublisher.Send(ev, cancellationToken);
return result;
}
}

View File

@ -1,5 +1,5 @@
using NetworkResurrector.Api.Domain.Entities;
using NetworkResurrector.Api.Domain.Models.Notifications;
using NetworkResurrector.Api.Domain.Models.ExternalNotifications;
namespace NetworkResurrector.Api.Application.Extensions
{

View File

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

View File

@ -0,0 +1,13 @@
using NetworkResurrector.Api.Domain.Models.InternalNotifications;
using System.Threading;
using System.Threading.Tasks;
namespace NetworkResurrector.Api.Application.Services.Abstractions
{
public interface IMessageHubPublisher
{
Task Broadcast<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage;
Task Send<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage;
Task SendToUser<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage;
}
}

View File

@ -1,5 +1,5 @@
using NetworkResurrector.Api.Domain.Constants;
using NetworkResurrector.Api.Domain.Models.Notifications;
using NetworkResurrector.Api.Domain.Models.ExternalNotifications;
using System.Threading;
using System.Threading.Tasks;
@ -7,7 +7,7 @@ namespace NetworkResurrector.Api.Application.Services.Abstractions
{
public interface INotificationService
{
Task Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default);
Task<NotificationResult> Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default);
Task Notify(NotificationType type, string machineName, string machineFullName, string machineIP, string actionStatus, string actionPerformer, string errorMessage, CancellationToken cancellationToken = default);
Task NotifyError(string errorMessage, CancellationToken cancellationToken = default);
}

View File

@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging;
using NBB.Messaging.Abstractions;
using NetworkResurrector.Api.Application.Services.Abstractions;
using NetworkResurrector.Api.Domain.Constants;
using NetworkResurrector.Api.Domain.Models.Notifications;
using NetworkResurrector.Api.Domain.Models.ExternalNotifications;
using System;
using System.Linq;
using System.Text.RegularExpressions;
@ -96,7 +96,7 @@ namespace NetworkResurrector.Api.Application.Services
return placeHolderValue;
}
public async Task Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default)
public async Task<NotificationResult> Notify(NotificationType type, NotificationContext context, CancellationToken cancellationToken = default)
{
var notification = GetNotification(type, context);
var cmd = new SendEmail()
@ -108,6 +108,9 @@ namespace NetworkResurrector.Api.Application.Services
};
await _messageBusPublisher.PublishAsync(cmd, cancellationToken);
var result = NotificationResult.Create(notification.To);
return result;
}
public async Task NotifyError(string errorMessage, CancellationToken cancellationToken = default)

View File

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

View File

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

View File

@ -0,0 +1,15 @@
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
{
public record NotificationResult
{
public string[] To { get; init; }
public static NotificationResult Create(string[] to)
{
return new NotificationResult
{
To = to
};
}
}
}

View File

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

View File

@ -0,0 +1,9 @@
namespace NetworkResurrector.Api.Domain.Models.InternalNotifications
{
public record EmailSent : IRealtimeMessage
{
public string To { get; init; }
public string MachineName { get; init; }
public string Status { get; init; }
}
}

View File

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

View File

@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NetworkResurrector.Api.Application.Services.Abstractions;
using NetworkResurrector.Api.Domain.Models.InternalNotifications;
using System.Threading.Tasks;
namespace NetworkResurrector.Api.Controllers
{
[ApiController]
[Authorize]
[Route("api/realtime-notifications")]
public class RealtimeNotificationsController : ControllerBase
{
private readonly IMessageHubPublisher _messageHubPublisher;
private readonly ILogger<RealtimeNotificationsController> _logger;
public RealtimeNotificationsController(IMessageHubPublisher messageHubPublisher, ILogger<RealtimeNotificationsController> logger)
{
_messageHubPublisher = messageHubPublisher;
_logger = logger;
}
[HttpPost("broadcast")]
public async Task<IActionResult> Broadcast([FromBody] MessageModel message)
{
await _messageHubPublisher.Broadcast(message);
return Ok();
}
[HttpPost("message-to-client")]
public async Task<IActionResult> SendMessageToClient([FromBody] MessageModel message)
{
await _messageHubPublisher.Send(message);
return Ok();
}
[HttpPost("message-to-user")]
public async Task<IActionResult> SendMessageToUser([FromBody] MessageModel message)
{
await _messageHubPublisher.SendToUser(message);
return Ok();
}
}
public class MessageModel : IRealtimeMessage
{
}
}

View File

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

View File

@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;
using NetworkResurrector.Api.Application.Services.Abstractions;
using NetworkResurrector.Api.Services;
using System;
using System.Security.Claims;
namespace NetworkResurrector.Api.Extensions
{
public static class SignalRExtensions
{
private const string AUTH_QUERY_STRING_KEY = "access_token";
/// <summary>
/// Middleware that extracts a bearer token from the query string (using the "access_token" key)
/// and injects it into the Authorization header. This is necessary for SignalR connections,
/// which cannot send authentication tokens via headers during the WebSocket handshake.
/// Enables standard authentication handlers to process SignalR requests.
/// </summary>
public static void UseSignalRAuthentication(this IApplicationBuilder app)
{
app.Use(async (context, next) =>
{
var headers = context.Request.Headers;
if (string.IsNullOrWhiteSpace(headers[HeaderNames.Authorization]) &&
context.Request.Query.TryGetValue(AUTH_QUERY_STRING_KEY, out var token) &&
!string.IsNullOrWhiteSpace(token))
{
try
{
// Overwrite or set the Authorization header with the Bearer token from the query string
headers[HeaderNames.Authorization] = $"Tuitio {token}";
}
catch (InvalidOperationException)
{
// Ignore if setting the header fails (e.g., if headers are read-only at this point)
}
}
await next.Invoke();
});
}
/// <summary>
/// Gets the user identifier for the current connection
/// </summary>
/// <param name="context">HubCallerContext</param>
/// <returns>The user id or null if not authenticated</returns>
public static string GetUserId(this HubCallerContext context)
{
return context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
/// <summary>
/// Gets the username for the current connection
/// </summary>
/// <param name="context">HubCallerContext</param>
/// <returns>The username or null if not authenticated</returns>
public static string GetUsername(this HubCallerContext context)
{
return context.User?.FindFirst(ClaimTypes.Name)?.Value;
}
public static void AddSignalRNotifications(this IServiceCollection services)
{
services.AddSignalR();
services.AddScoped<IMessageHubPublisher, MessageHubPublisher>();
}
}
}

View File

@ -13,18 +13,22 @@ using NetworkResurrector.Agent.Wrapper;
using NetworkResurrector.Api.Application;
using NetworkResurrector.Api.Authorization;
using NetworkResurrector.Api.Domain.Data;
using NetworkResurrector.Api.Hubs;
using NetworkResurrector.Server.Wrapper;
using Newtonsoft.Json;
namespace NetworkResurrector.Api.Extensions
{
public static class StartupExtensions
{
{
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddControllers()
.AddNewtonsoftJson(o => o.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc);
// Add realtime notifications
services.AddSignalRNotifications();
// Add basic authentication
services.AddTuitioAuthentication(configuration.GetSection("Tuitio")["BaseAddress"]);
@ -62,18 +66,24 @@ namespace NetworkResurrector.Api.Extensions
services.AddMessageBus(configuration);
}
public static void Configure(this IApplicationBuilder app)
{
// global cors policy
app.UseCors(x => x.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
public static void Configure(this IApplicationBuilder app, IConfiguration configuration)
{
var origins = configuration.GetSection("AllowedOrigins").Get<string[]>();
app.UseCors(x => x
.WithOrigins(origins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
app.UseExceptionHandler("/error");
app.UseSignalRAuthentication();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapHub<MessageHub>("/hubs/notifications");
});
app.ConfigureSwagger("NetworkResurrector API");

View File

@ -0,0 +1,96 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using NetworkResurrector.Api.Extensions;
namespace NetworkResurrector.Api.Hubs
{
[Authorize]
public class MessageHub : Hub
{
private readonly ILogger<MessageHub> _logger;
public MessageHub(ILogger<MessageHub> logger)
{
_logger = logger;
}
public override async Task OnConnectedAsync()
{
var userId = Context.GetUserId();
var username = Context.GetUsername();
_logger.LogInformation($"Client connected: ConnectionId={Context.ConnectionId}, UserId={userId}, Username={username}");
if (!string.IsNullOrEmpty(username))
{
var groupName = GetGroupName(username);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation($"Added connection {Context.ConnectionId} to user group {groupName}");
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception exception)
{
var userId = Context.GetUserId();
var username = Context.GetUsername();
_logger.LogInformation($"Client disconnected: ConnectionId={Context.ConnectionId}, UserId={userId}, Username={username}");
if (!string.IsNullOrEmpty(username))
{
var groupName = GetGroupName(username);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation($"Removed connection {Context.ConnectionId} from user group {groupName}");
}
await base.OnDisconnectedAsync(exception);
}
private string GetGroupName(string id) => $"User_{id}";
public async Task SendMessage(string message)
{
_logger.LogInformation($"Received message from {Context.ConnectionId}: {message}");
await Clients.All.SendAsync("ReceiveMessage", message);
}
public async Task SendNotification(string title, string message)
{
_logger.LogInformation($"Sending notification: {title} - {message}");
await Clients.All.SendAsync("ReceiveNotification", title, message);
}
public async Task JoinGroup(string groupName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation($"Client {Context.ConnectionId} joined group: {groupName}");
await Clients.Group(groupName).SendAsync("GroupNotification", $"User joined {groupName} group");
}
public async Task SendToGroup(string groupName, string message)
{
_logger.LogInformation($"Message to group {groupName}: {message}");
await Clients.Group(groupName).SendAsync("ReceiveGroupMessage", groupName, message);
}
public async Task SendToClient(string connectionId, string message)
{
_logger.LogInformation($"Sending message to client {connectionId}: {message}");
await Clients.Client(connectionId).SendAsync("ReceiveMessage", message);
}
public async Task SendToUser(string userId, string title, string message)
{
var senderUserId = Context.GetUserId();
var senderUsername = Context.GetUsername();
_logger.LogInformation($"Sending notification from user {senderUserId} ({senderUsername}) to user {userId}: {title} - {message}");
await Clients.User(userId).SendAsync("ReceiveNotification", title, message);
}
}
}

View File

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

View File

@ -26,7 +26,7 @@ namespace NetworkResurrector.Api
builder.Services.ConfigureServices(builder.Configuration);
var app = builder.Build();
app.Configure();
app.Configure(builder.Configuration);
var exitCode = 0;
try

View File

@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using NetworkResurrector.Api.Application.Services.Abstractions;
using NetworkResurrector.Api.Domain.Models.InternalNotifications;
using NetworkResurrector.Api.Hubs;
using System;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace NetworkResurrector.Api.Services
{
public class MessageHubPublisher : IMessageHubPublisher
{
private const string ConnectionIdHeader = "SignalR-ConnectionId";
private readonly IHubContext<MessageHub> _hubContext;
private readonly ILogger<MessageHubPublisher> _logger;
private readonly string _connectionId;
private readonly string _userId;
public MessageHubPublisher(IHubContext<MessageHub> hubContext, ILogger<MessageHubPublisher> logger, IHttpContextAccessor httpContextAccessor)
{
_hubContext = hubContext;
_logger = logger;
var headers = httpContextAccessor.HttpContext?.Request.Headers;
if (headers.TryGetValue(ConnectionIdHeader, out var connectionIdValue) == true)
_connectionId = connectionIdValue.ToString();
else
_connectionId = string.Empty;
_userId = httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
/// <summary>
/// Broadcasts a message to all connected clients.
/// </summary>
public async Task Broadcast<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage
{
if (message is null)
throw new ArgumentNullException(nameof(message), "Message cannot be null");
var notification = RealtimeNotification<T>.Create(message, _connectionId);
_logger.LogInformation($"Broadcasting message of type: {notification.Type}.");
await _hubContext.Clients.All.SendAsync("ReceiveNotification", notification, cancellationToken);
}
/// <summary>
/// Sends a message to the connected client identified by the connection ID.
/// </summary>
public async Task Send<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage
{
if (message is null)
throw new ArgumentNullException(nameof(message), "Message cannot be null");
var notification = RealtimeNotification<T>.Create(message, _connectionId);
_logger.LogInformation($"Sending message of type: {notification.Type}.");
await _hubContext.Clients.Client(_connectionId).SendAsync("ReceiveNotification", notification, cancellationToken);
}
/// <summary>
/// Sends a message to the authenticated user identified by the user ID.
/// </summary>
public async Task SendToUser<T>(T message, CancellationToken cancellationToken = default) where T : class, IRealtimeMessage
{
if (message is null)
throw new ArgumentNullException(nameof(message), "Message cannot be null");
if (string.IsNullOrEmpty(_userId))
throw new InvalidOperationException("User ID is not available. Ensure the user is authenticated.");
var notification = RealtimeNotification<T>.Create(message, _connectionId);
_logger.LogInformation($"Sending message of type: {notification.Type}.");
await _hubContext.Clients.User(_userId).SendAsync("ReceiveNotification", notification, cancellationToken);
}
}
}

View File

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

View File

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

View File

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

View File

@ -3,5 +3,4 @@ node_modules
build
__mocks__
.vscode
helm
private
helm

View File

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

View File

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

View File

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

1
frontend/.gitignore vendored
View File

@ -10,6 +10,7 @@
# production
/build
/build2
# misc
.DS_Store

View File

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

View File

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

View File

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

68
frontend/eslint.config.js Normal file
View File

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

35
frontend/index.html Normal file
View File

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

36586
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -22,8 +22,12 @@
"Dashboard": "Dashboard",
"Machines": "Machines",
"System": "System",
"Administration": "Administration",
"Administration": { "Title": "Administration", "Machines": "Machines", "Agents": "Agents" },
"Settings": "Settings",
"Debugging": {
"Title": "Debugging",
"Notifications": "Notifications"
},
"About": "About"
},
"ViewModes": {

View File

@ -13,8 +13,12 @@
"Dashboard": "Bord",
"Machines": "Mașini",
"System": "Sistem",
"Administration": "Administrare",
"Administration": { "Title": "Administrare", "Machines": "Mașini", "Agents": "Agenți" },
"Settings": "Setări",
"Debugging": {
"Title": "Depanare",
"Notifications": "Notificări"
},
"About": "Despre"
},
"ViewModes": {

View File

@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"name": "Vite React App",
"icons": [
{
"src": "favicon.ico",

101
frontend/runtimeSetup.js Normal file
View File

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

View File

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

View File

@ -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 = () => {

View File

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

View File

@ -0,0 +1,21 @@
import { Tooltip, TooltipProps, styled } from "@mui/material";
const Hint = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)(({ theme }) => ({
[`& .MuiTooltip-tooltip`]: {
backgroundColor: theme.palette.mode === "dark" ? "rgba(48, 48, 48, 0.95)" : "rgba(255, 255, 255, 0.95)",
color: theme.palette.text.primary,
border: `1px solid ${theme.palette.divider}`,
backdropFilter: "blur(8px)",
boxShadow: theme.shadows[8],
//fontSize: theme.typography.body2.fontSize,
borderRadius: theme.shape.borderRadius
},
[`& .MuiTooltip-arrow`]: {
color: theme.palette.mode === "dark" ? "rgba(48, 48, 48, 0.95)" : "rgba(255, 255, 255, 0.95)",
"&::before": {
border: `1px solid ${theme.palette.divider}`
}
}
}));
export default Hint;

View File

@ -1,5 +1,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;

View File

@ -1,5 +1,6 @@
import DataLabel from "./DataLabel";
import PaperTitle from "./PaperTitle";
import FlagIcon from "./FlagIcon";
import Hint from "./Hint";
export { DataLabel, PaperTitle, FlagIcon };
export { DataLabel, PaperTitle, FlagIcon, Hint };

View File

@ -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 }) => {

View File

@ -1 +1,15 @@
export { Home, Dashboard, Dns, DeviceHub, Build, Settings, FeaturedPlayList, Info } from "@mui/icons-material";
export {
Home,
Dashboard,
Dns,
Hub,
DeviceHub,
Build,
Settings,
FeaturedPlayList,
Info,
Adb,
Notifications,
Devices,
Stream
} from "@mui/icons-material";

View File

@ -7,6 +7,7 @@ import SettingsContainer from "../../features/settings/SettingsContainer";
import DashboardContainer from "../../features/dashboard/DashboardContainer";
import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer";
import AboutContainer from "../../features/about/AboutContainer";
import NotificationDemo from "features/debugging/notifications/NotificationDemo";
const AppRoutes: React.FC = () => {
return (
@ -16,6 +17,7 @@ const AppRoutes: React.FC = () => {
<Route path="/machines" element={<NetworkContainer />} />
<Route path="/system" element={<SystemContainer />} />
<Route path="/settings" element={<SettingsContainer />} />
<Route path="/debugging/notifications" element={<NotificationDemo />} />
<Route path="/about" element={<AboutContainer />} />
<Route path="/*" element={<PageNotFound />} />
</Routes>

View File

@ -1,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();

View File

@ -1,4 +1,4 @@
import * as React from "react";
import React from "react";
import { styled, Theme, CSSObject } from "@mui/material/styles";
import MuiDrawer from "@mui/material/Drawer";
import List from "@mui/material/List";
@ -67,8 +67,6 @@ const SideBar: React.FC<SideBarProps> = ({ open, onDrawerOpen, onDrawerClose })
const navigate = useNavigate();
const { t } = useTranslation();
menu.sort((a, b) => (a.order || 0) - (b.order || 0));
return (
<Drawer variant="permanent" open={open}>
<DrawerHeader>

View File

@ -8,6 +8,7 @@ import SensitiveInfoToggle from "./SensitiveInfoToggle";
import { styled } from "@mui/material/styles";
import { drawerWidth } from "./constants";
import { ProgressBar } from "units/progress";
import AppNotifications from "./notifications/AppNotifications";
interface AppBarProps extends MuiAppBarProps {
open?: boolean;
@ -60,6 +61,7 @@ const TopBar: React.FC<TopBarProps> = ({ open, onDrawerOpen }) => {
</Typography>
<Box sx={{ flexGrow: 1 }} />
<Box sx={{ display: { xs: "none", md: "flex" }, gap: 1 }}>
<AppNotifications />
<SensitiveInfoToggle />
<LightDarkToggle />
<ProfileButton />

View File

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

View File

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

View File

@ -0,0 +1,114 @@
import React, { useCallback, useState } from "react";
import { Badge, IconButton, Popover } from "@mui/material";
import { Notifications } from "@mui/icons-material";
import PopoverContent from "./PopoverContent";
import { AppNotificationPayload, AppNotification, NotificationLevel, EmailSentPayload } from "./types";
import { shortid } from "utils/uid";
import { Hint } from "components/common";
import { RealtimeNotification } from "units/notifications/types";
import { notificationTypes } from "../../../constants";
import { useSubscription } from "hooks";
const UNREAD_NOTIFICATIONS_DOT_LIMIT = 9;
type Props = {
disabled?: boolean;
};
const AppNotifications: React.FC<Props> = ({ disabled }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const push = useCallback((notification: AppNotification) => {
setNotifications(prev => [...prev, notification]);
}, []);
const handleNotificationReceive = useCallback(
(notification: RealtimeNotification<AppNotificationPayload>) => {
const n: AppNotification = {
...notification.payload,
id: shortid(),
moment: notification.payload.moment ?? new Date(),
level: notification.payload.level ?? NotificationLevel.INFO,
read: false
};
push(n);
},
[push]
);
useSubscription<AppNotificationPayload>(notificationTypes.APP_NOTIFICATION_RECEIVED, {
onNotification: handleNotificationReceive
});
useSubscription<EmailSentPayload>(notificationTypes.EMAIL_SENT, {
onNotification: notification => {
const n: AppNotification = {
id: shortid(),
moment: new Date(),
level: NotificationLevel.INFO,
content: `Email was sent to ${notification.payload.to} informing that the machine ${notification.payload.machineName} has been woken up.`,
read: false
};
push(n);
}
});
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleRemove = useCallback((id: string) => {
setNotifications(prev => prev.filter(notification => notification.id !== id));
}, []);
const handleMarkAllAsRead = useCallback(() => {
setNotifications(prev => prev.map(notification => ({ ...notification, read: true })));
}, []);
const handleClose = () => {
handleMarkAllAsRead();
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = open ? "app-notifications-popover" : undefined;
const unread = notifications.filter(notification => !notification.read).length;
const variant = unread > UNREAD_NOTIFICATIONS_DOT_LIMIT ? "dot" : "standard";
return (
<>
<Hint title="Notifications" arrow>
<IconButton aria-label={id} color="inherit" disabled={disabled} onClick={handleClick}>
<Badge
color="warning"
badgeContent={unread}
variant={variant}
overlap="circular"
anchorOrigin={{
vertical: "top",
horizontal: "right"
}}
>
<Notifications />
</Badge>
</IconButton>
</Hint>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left"
}}
slotProps={{ paper: { sx: { width: 450 } } }}
>
<PopoverContent notifications={notifications} onRemove={handleRemove} onMarkAllAsRead={handleMarkAllAsRead} />
</Popover>
</>
);
};
export default AppNotifications;

View File

@ -0,0 +1,173 @@
import React, { useMemo, useState } from "react";
import { Avatar, Box, IconButton, List, ListItem, ListItemAvatar, ListItemText, Tooltip, Typography } from "@mui/material";
import { AppNotification, NotificationLevel } from "./types";
import {
MarkEmailReadOutlined,
CloseOutlined,
InfoOutlined,
CheckCircleOutlined,
ReportOutlined,
ErrorOutlineOutlined,
MarkEmailUnreadOutlined,
EmailOutlined
} from "@mui/icons-material";
import { styled } from "@mui/material/styles";
const PrimarySpan = styled("span")(({ theme }) => ({
color: theme.palette.primary.main
}));
type Props = {
notifications: AppNotification[];
onRemove: (id: string) => void;
onMarkAllAsRead: () => void;
};
const PopoverContent: React.FC<Props> = ({ notifications, onRemove, onMarkAllAsRead }) => {
const [viewUnreadOnly, setViewUnreadOnly] = useState<boolean>(false);
const isEmpty = notifications.length === 0;
const filteredNotifications = useMemo(() => {
const list = viewUnreadOnly ? notifications.filter(n => !n.read) : [...notifications];
list.sort((a, b) => b.moment.getTime() - a.moment.getTime());
return list;
}, [viewUnreadOnly, notifications]);
return (
<>
<Box
sx={{
alignItems: "center",
backgroundColor: "primary.main",
color: "primary.contrastText",
display: "flex",
justifyContent: "space-between",
px: 1,
py: 0.5
}}
>
<Typography color="inherit" variant="subtitle1">
Notifications
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "flex-end"
}}
>
<Tooltip title={viewUnreadOnly ? "View all" : "View unread only"}>
<span>
<IconButton onClick={() => setViewUnreadOnly(prev => !prev)} size="small" color="inherit" disabled={isEmpty}>
{viewUnreadOnly ? <EmailOutlined fontSize="small" /> : <MarkEmailUnreadOutlined fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Mark all as read">
<span>
<IconButton onClick={onMarkAllAsRead} size="small" color="inherit" disabled={isEmpty}>
<MarkEmailReadOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{isEmpty ? (
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ opacity: 0.6 }}>
There are no notifications
</Typography>
</Box>
) : (
<Box
sx={{
maxHeight: 400,
overflowY: "auto",
"&::-webkit-scrollbar": {
width: "8px"
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "rgba(0,0,0,0.2)",
borderRadius: "4px"
},
"&::-webkit-scrollbar-track": {
backgroundColor: "rgba(0,0,0,0.05)"
}
}}
>
<List disablePadding>
{filteredNotifications.map(notification => (
<ListItem
divider
key={notification.id}
sx={{
alignItems: "flex-start",
"&:hover": { backgroundColor: "action.hover" },
"& .MuiListItemSecondaryAction-root": { top: "24%" }
}}
secondaryAction={
<Tooltip title="Remove">
<IconButton edge="end" onClick={() => onRemove(notification.id)} size="small">
<CloseOutlined sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
}
>
{getNotificationContent(notification)}
</ListItem>
))}
</List>
</Box>
)}
</>
);
};
const getNotificationContent = (notification: AppNotification): React.ReactElement | null => {
const isSuccess = notification.level === NotificationLevel.SUCCESS;
const isWarning = notification.level === NotificationLevel.WARNING;
const isError = notification.level === NotificationLevel.ERROR;
const RowIcon = isSuccess ? (
<CheckCircleOutlined color="success" />
) : isWarning ? (
<ReportOutlined color="warning" />
) : isError ? (
<ErrorOutlineOutlined color="error" />
) : (
<InfoOutlined color="info" />
);
return (
<>
<ListItemAvatar sx={{ mt: 0.5 }}>
<Avatar sx={{ bgcolor: "rgba(0, 0, 0, 0.1)", color: "primary.main" }}>{RowIcon}</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box
sx={{
alignItems: "flex-start",
display: "flex",
flexDirection: "column"
}}
>
<Typography sx={{ mb: 0.5, fontWeight: "bold" }} variant="body2">
{notification.author && <PrimarySpan>{notification.author} </PrimarySpan>}
{notification.title}
</Typography>
<Typography variant="body2">{notification.content}</Typography>
</Box>
}
secondary={
<Typography color="textSecondary" variant="caption">
{notification.moment.toLocaleString()}
</Typography>
}
sx={{ my: 0 }}
/>
</>
);
};
export default PopoverContent;

View File

@ -0,0 +1,29 @@
export enum NotificationLevel {
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error"
}
export type AppNotificationPayload = {
title?: string;
content: string;
author?: string;
moment?: Date;
level?: NotificationLevel;
};
export type AppNotification = {
id: string;
title?: string;
content: string;
author?: string;
moment: Date;
level: NotificationLevel;
read: boolean;
};
export type EmailSentPayload = {
to: string;
machineName: string;
};

View File

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

View File

@ -0,0 +1,9 @@
// server to client notifications
export const APP_NOTIFICATION_RECEIVED = "APP_NOTIFICATION_RECEIVED";
export const EMAIL_SENT = "NetworkResurrector.Api.Domain.Models.InternalNotifications.EmailSent";
export const MACHINE_DELETED = "MACHINE_DELETED";
export const MACHINE_DELETE_FAILED = "MACHINE_DELETE_FAILED";
// client to server notifications
export const MACHINES_PAGE_OPENED = "MACHINES_PAGE_OPENED";
export const MACHINES_PAGE_CLOSED = "MACHINES_PAGE_CLOSED";

View File

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

View File

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

View File

@ -0,0 +1,153 @@
import React, { useState } from "react";
import { Box, Button, Card, CardContent, Grid, TextField, Typography } from "@mui/material";
import SendIcon from "@mui/icons-material/Send";
import axios from "axios";
import { useRealtimeNotifications } from "units/notifications/hooks";
import env from "utils/env";
import { acquire as fetchTuitioData } from "@flare/tuitio-client";
const API_URL = env.VITE_APP_API_URL;
const getHeaders = (): Record<string, string> => {
const { token } = fetchTuitioData();
const headers: Record<string, string> = {
"Content-Type": "application/json"
};
if (token) {
headers.Authorization = `Tuitio ${token}`;
}
return headers;
};
const headers = getHeaders();
const axiosInstance = axios.create({
baseURL: API_URL,
headers
});
const NotificationApiTest: React.FC = () => {
const { connectionId } = useRealtimeNotifications();
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [userId, setUserId] = useState("");
const [responseStatus, setResponseStatus] = useState<{ success: boolean; message: string } | null>(null);
// Handle broadcasting a notification through the API
const handleBroadcastNotification = async () => {
try {
const response = await axiosInstance.post("/api/realtime-notifications/broadcast", {
title: title || "API Test Notification",
message: message || `This is a test notification sent from the API at ${new Date().toLocaleTimeString()}`
});
console.log("Broadcast response:", response.data);
setResponseStatus({ success: true, message: "Broadcast sent successfully" });
setTitle("");
setMessage("");
} catch (error) {
console.error("Error sending broadcast:", error);
setResponseStatus({ success: false, message: "Failed to send broadcast" });
}
};
// Handle sending a notification to a specific user
const handleSendToUser = async () => {
try {
const response = await axiosInstance.post("/api/Notifications/user", {
userId: userId,
title: title || "User-specific Notification",
message: message || `This notification was sent to user ${userId} at ${new Date().toLocaleTimeString()}`
});
console.log("User notification response:", response.data);
setResponseStatus({ success: true, message: "User notification sent successfully" });
setTitle("");
setMessage("");
} catch (error) {
console.error("Error sending user notification:", error);
setResponseStatus({ success: false, message: "Failed to send user notification" });
}
};
return (
<Box sx={{ p: 1 }}>
<Typography variant="h6" gutterBottom>
Test Notification API Endpoints
</Typography>
{/* Display connection ID info */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
{connectionId ? `Current connection ID: ${connectionId}` : "Not connected to SignalR hub. Connect first to receive notifications."}
</Typography>
</Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Broadcast Notification (API)
</Typography>
<TextField label="Title" fullWidth value={title} onChange={e => setTitle(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={message} onChange={e => setMessage(e.target.value)} margin="normal" />
<Button variant="contained" color="primary" startIcon={<SendIcon />} onClick={handleBroadcastNotification} sx={{ mt: 2 }}>
Broadcast via API
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User-specific Notification (API)
</Typography>
<TextField
label="User ID"
fullWidth
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="Enter connection ID to receive notification"
margin="normal"
/>
<TextField label="Title" fullWidth value={title} onChange={e => setTitle(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={message} onChange={e => setMessage(e.target.value)} margin="normal" />
<Button variant="contained" color="warning" startIcon={<SendIcon />} onClick={handleSendToUser} sx={{ mt: 2 }} disabled={!userId.trim()}>
Send to User via API
</Button>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Response status */}
{responseStatus && (
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 1,
bgcolor: responseStatus.success ? "success.light" : "error.light"
}}
>
<Typography variant="body2" color="textPrimary">
{responseStatus.message}
</Typography>
</Box>
)}
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary">
Note: For user-specific notifications, enter a valid connection ID in the User ID field. You can find your connection ID at the top of this page when
connected to SignalR.
</Typography>
</Box>
</Box>
);
};
export default NotificationApiTest;

View File

@ -0,0 +1,358 @@
import React, { useState, useEffect } from "react";
import {
Alert,
Badge,
Box,
Button,
Drawer,
IconButton,
List,
ListItem,
Snackbar,
Typography,
Paper,
TextField,
Grid,
Card,
CardContent,
Chip,
Divider
} from "@mui/material";
import NotificationsIcon from "@mui/icons-material/Notifications";
import CloseIcon from "@mui/icons-material/Close";
import SendIcon from "@mui/icons-material/Send";
import DeleteIcon from "@mui/icons-material/Delete";
import { useRealtimeNotifications } from "units/notifications/hooks";
import { ConnectionStatus } from "./parts";
import NotificationApiTest from "./NotificationApiTest";
type TestNotification = {
title: string;
message: string;
};
const TEST_NOTIFICATION_TYPE = "Notifications.Test";
const NotificationDemo: React.FC = () => {
const { notifications, messages, sendMessage, sendNotification, joinGroup, sendToGroup, sendToClient, sendToUser } = useRealtimeNotifications();
const [open, setOpen] = useState(false);
const [openSnackbar, setOpenSnackbar] = useState(false);
const [currentNotification, setCurrentNotification] = useState<TestNotification>({ title: "", message: "" });
// Form states
const [messageText, setMessageText] = useState("");
const [notificationTitle, setNotificationTitle] = useState("");
const [notificationMessage, setNotificationMessage] = useState("");
const [groupName, setGroupName] = useState("");
const [groupMessage, setGroupMessage] = useState("");
const [joinedGroups, setJoinedGroups] = useState<string[]>([]);
const [userId, setUserId] = useState("");
const [clientConnectionId, setClientConnectionId] = useState("");
const [userNotificationTitle, setUserNotificationTitle] = useState("");
const [userNotificationMessage, setUserNotificationMessage] = useState("");
// Handle sending a test message
const handleSendTestMessage = () => {
if (messageText.trim()) {
sendMessage(messageText);
setMessageText("");
} else {
sendMessage(`Test message at ${new Date().toLocaleTimeString()}`);
}
};
// Handle sending a test notification
const handleSendTestNotification = () => {
if (notificationTitle.trim() && notificationMessage.trim()) {
const payload: TestNotification = {
title: notificationTitle,
message: notificationMessage
};
const notification = {
type: TEST_NOTIFICATION_TYPE,
payload
};
sendNotification(notification);
setNotificationTitle("");
setNotificationMessage("");
} else {
const payload: TestNotification = {
title: "Test Notification",
message: `This is a test notification sent at ${new Date().toLocaleTimeString()}`
};
const notification = {
type: TEST_NOTIFICATION_TYPE,
payload
};
sendNotification(notification);
}
};
// Handle joining a group
const handleJoinGroup = () => {
if (groupName.trim() && !joinedGroups.includes(groupName)) {
joinGroup(groupName);
setJoinedGroups(prev => [...prev, groupName]);
}
};
// Handle sending a message to a group
const handleSendToGroup = () => {
if (groupName.trim() && groupMessage.trim()) {
sendToGroup(groupName, groupMessage);
setGroupMessage("");
}
};
// Handle sending a notification to a specific user
const handleSendToUser = () => {
if (userId.trim() && userNotificationTitle.trim() && userNotificationMessage.trim()) {
sendToUser(userId, userNotificationTitle, userNotificationMessage);
setUserNotificationTitle("");
setUserNotificationMessage("");
}
};
const handleSendToClient = () => {
if (!clientConnectionId.trim() || !messageText.trim()) return;
sendToClient(clientConnectionId, messageText);
setClientConnectionId("");
setMessageText("");
};
// Clear all messages
const handleClearMessages = () => {
// We can't directly clear the messages in the SignalR context,
// but we can simulate a refresh
window.location.reload();
};
// Show the most recent notification in a snackbar
useEffect(() => {
if (notifications.length > 0) {
const latestNotification = notifications[notifications.length - 1];
let payload: TestNotification;
if (latestNotification.type !== TEST_NOTIFICATION_TYPE) {
payload = {
title: "Unknown Notification",
message: `Received notification of type ${latestNotification.type}`
};
} else {
payload = latestNotification.payload as TestNotification;
}
setCurrentNotification(payload);
setOpenSnackbar(true);
}
}, [notifications]);
return (
<>
<Box sx={{ p: 1 }}>
<Box sx={{ mb: 2, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="h6">SignalR Demo</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
<IconButton color="inherit" onClick={() => setOpen(true)}>
<Badge badgeContent={notifications.length} color="error">
<NotificationsIcon />
</Badge>
</IconButton>
<ConnectionStatus />
</Box>
</Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Send message
</Typography>
<TextField label="Message" fullWidth value={messageText} onChange={e => setMessageText(e.target.value)} margin="normal" />
<Button variant="contained" startIcon={<SendIcon />} onClick={handleSendTestMessage} sx={{ mt: 2 }}>
Send test message
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Send notification
</Typography>
<TextField label="Title" fullWidth value={notificationTitle} onChange={e => setNotificationTitle(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={notificationMessage} onChange={e => setNotificationMessage(e.target.value)} margin="normal" />
<Button variant="contained" color="secondary" startIcon={<SendIcon />} onClick={handleSendTestNotification} sx={{ mt: 2 }}>
Send notification
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Group messages
</Typography>
<TextField label="Group name" fullWidth value={groupName} onChange={e => setGroupName(e.target.value)} margin="normal" />
<Button variant="outlined" onClick={handleJoinGroup} sx={{ mt: 2, mr: 2 }}>
Join group
</Button>
{joinedGroups.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Joined groups:
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{joinedGroups.map((group, index) => (
<Chip key={index} label={group} />
))}
</Box>
</Box>
)}
<TextField label="Group message" fullWidth value={groupMessage} onChange={e => setGroupMessage(e.target.value)} margin="normal" />
<Button
variant="contained"
color="primary"
startIcon={<SendIcon />}
onClick={handleSendToGroup}
sx={{ mt: 2 }}
disabled={joinedGroups.length === 0}
>
Send to group
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Client-specific message
</Typography>
<TextField label="Connection ID" fullWidth value={clientConnectionId} onChange={e => setClientConnectionId(e.target.value)} margin="normal" />
<TextField label="Message" fullWidth value={messageText} onChange={e => setMessageText(e.target.value)} margin="normal" />
<Button
variant="contained"
color="warning"
startIcon={<SendIcon />}
onClick={handleSendToClient}
sx={{ mt: 2 }}
disabled={!clientConnectionId.trim()}
>
Send to client
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User-specific notifications
</Typography>
<TextField label="User ID" fullWidth value={userId} onChange={e => setUserId(e.target.value)} margin="normal" />
<TextField label="Title" fullWidth value={userNotificationTitle} onChange={e => setUserNotificationTitle(e.target.value)} margin="normal" />
<TextField
label="Message"
fullWidth
value={userNotificationMessage}
onChange={e => setUserNotificationMessage(e.target.value)}
margin="normal"
/>
<Button variant="contained" color="warning" startIcon={<SendIcon />} onClick={handleSendToUser} sx={{ mt: 2 }} disabled={!userId.trim()}>
Send to user
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Received messages
</Typography>
<Box sx={{ maxHeight: "200px", overflowY: "auto", border: 1, borderColor: "divider", p: 1 }}>
{messages.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No messages received yet
</Typography>
) : (
<List dense>
{messages.map((message, index) => (
<ListItem key={index} divider={index < messages.length - 1}>
<Typography variant="body2">{message}</Typography>
</ListItem>
))}
</List>
)}
</Box>
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={handleClearMessages} sx={{ mt: 2 }}>
Clear messages
</Button>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Notification Drawer */}
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
<Box sx={{ width: 320, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">Notifications</Typography>
<IconButton onClick={() => setOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
{notifications.length === 0 ? (
<Paper elevation={0} sx={{ p: 2, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
No notifications
</Typography>
</Paper>
) : (
<List>
{notifications.map((notification, index) => (
<Paper key={index} elevation={1} sx={{ mb: 2, overflow: "hidden" }}>
<ListItem sx={{ bgcolor: "background.paper" }}>
<Box sx={{ width: "100%" }}>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{notification.type}
</Typography>
<Typography variant="body2">{JSON.stringify(notification.payload)}</Typography>
</Box>
</ListItem>
</Paper>
))}
</List>
)}
</Box>
</Drawer>
{/* Notification Snackbar */}
<Snackbar open={openSnackbar} autoHideDuration={6000} onClose={() => setOpenSnackbar(false)} anchorOrigin={{ vertical: "bottom", horizontal: "right" }}>
<Alert onClose={() => setOpenSnackbar(false)} severity="info" sx={{ width: "100%" }}>
<Typography variant="subtitle2">{currentNotification.title}</Typography>
<Typography variant="body2">{currentNotification.message}</Typography>
</Alert>
</Snackbar>
</Box>
<Divider sx={{ my: 2 }} />
<NotificationApiTest />
</>
);
};
export default NotificationDemo;

View File

@ -0,0 +1,36 @@
import React from "react";
import { Box, Typography } from "@mui/material";
import { useRealtimeNotifications } from "units/notifications/hooks";
const ConnectionStatus: React.FC = () => {
const { isConnected, connectionId } = useRealtimeNotifications();
return (
<Box sx={{ ml: 2, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Connection status:
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: isConnected ? "success.main" : "error.main"
}}
/>
<Typography variant="body2" sx={{ ml: 1 }}>
{isConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
{connectionId && (
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.75rem", mt: 0.5 }}>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Connection ID: {connectionId}
</Typography>
</Box>
)}
</Box>
);
};
export default ConnectionStatus;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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