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>
|
<Project>
|
||||||
<Import Project="dependencies.props" />
|
<Import Project="dependencies.props" />
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.3.1</Version>
|
<Version>1.4.2</Version>
|
||||||
<Authors>Tudor Stanciu</Authors>
|
<Authors>Tudor Stanciu</Authors>
|
||||||
<Company>STA</Company>
|
<Company>STA</Company>
|
||||||
<PackageTags>NetworkResurrector</PackageTags>
|
<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.
|
• The progress bar is displayed at the top of the page and shows the loading status of the application.
|
||||||
</Content>
|
</Content>
|
||||||
</Note>
|
</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>
|
</ReleaseNotes>
|
|
@ -4,16 +4,16 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
-->
|
-->
|
||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||||
<PublishProvider>FileSystem</PublishProvider>
|
<PublishProvider>FileSystem</PublishProvider>
|
||||||
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
|
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
|
||||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||||
<SiteUrlToLaunchAfterPublish />
|
<SiteUrlToLaunchAfterPublish />
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<ProjectGuid>c8c4ca6f-39e2-46fe-89e2-0a81d2f4161e</ProjectGuid>
|
<ProjectGuid>c8c4ca6f-39e2-46fe-89e2-0a81d2f4161e</ProjectGuid>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
{
|
{
|
||||||
"Urls": "http://*:5068",
|
"Urls": "http://*:5068",
|
||||||
"ConnectionStrings": {
|
|
||||||
"DatabaseConnection": "Server=#########;Database=#########;User Id=#########;Password=#########;MultipleActiveResultSets=true"
|
|
||||||
},
|
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information"
|
"Default": "Information"
|
||||||
|
|
|
@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||||
using NetworkResurrector.Api.Application.Extensions;
|
using NetworkResurrector.Api.Application.Extensions;
|
||||||
using NetworkResurrector.Api.Application.Services.Abstractions;
|
using NetworkResurrector.Api.Application.Services.Abstractions;
|
||||||
using NetworkResurrector.Api.Domain.Constants;
|
using NetworkResurrector.Api.Domain.Constants;
|
||||||
|
using NetworkResurrector.Api.Domain.Models.InternalNotifications;
|
||||||
using NetworkResurrector.Api.Domain.Repositories;
|
using NetworkResurrector.Api.Domain.Repositories;
|
||||||
using NetworkResurrector.Api.PublishedLanguage.Commands;
|
using NetworkResurrector.Api.PublishedLanguage.Commands;
|
||||||
using NetworkResurrector.Api.PublishedLanguage.Events;
|
using NetworkResurrector.Api.PublishedLanguage.Events;
|
||||||
|
@ -19,13 +20,15 @@ namespace NetworkResurrector.Api.Application.CommandHandlers
|
||||||
private readonly IResurrectorService _resurrectorService;
|
private readonly IResurrectorService _resurrectorService;
|
||||||
private readonly INetworkRepository _repository;
|
private readonly INetworkRepository _repository;
|
||||||
private readonly INotificationService _notificationService;
|
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;
|
_logger=logger;
|
||||||
_resurrectorService=resurrectorService;
|
_resurrectorService=resurrectorService;
|
||||||
_repository=repository;
|
_repository=repository;
|
||||||
_notificationService=notificationService;
|
_notificationService=notificationService;
|
||||||
|
_messageHubPublisher=messageHubPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MachineWaked> Handle(WakeMachine command, CancellationToken cancellationToken)
|
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);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
using NetworkResurrector.Api.Domain.Entities;
|
using NetworkResurrector.Api.Domain.Entities;
|
||||||
using NetworkResurrector.Api.Domain.Models.Notifications;
|
using NetworkResurrector.Api.Domain.Models.ExternalNotifications;
|
||||||
|
|
||||||
namespace NetworkResurrector.Api.Application.Extensions
|
namespace NetworkResurrector.Api.Application.Extensions
|
||||||
{
|
{
|
||||||
|
|
|
@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||||
using NetworkResurrector.Server.Wrapper.Services;
|
using NetworkResurrector.Server.Wrapper.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
@ -35,12 +36,12 @@ namespace NetworkResurrector.Api.Application.Queries
|
||||||
public async Task<Model> Handle(Query request, CancellationToken cancellationToken)
|
public async Task<Model> Handle(Query request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var apiVersion = GetApiVersion();
|
var apiVersion = GetApiVersion();
|
||||||
var serverVersion = await GetServerVersion();
|
var (version, lastUpdateDate) = await GetServerVersion();
|
||||||
|
|
||||||
var result = new Model
|
var result = new Model
|
||||||
{
|
{
|
||||||
Api = apiVersion,
|
Api = apiVersion,
|
||||||
Server = serverVersion
|
Server = new ServiceVersion(FormatVersion(version), lastUpdateDate)
|
||||||
};
|
};
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -52,7 +53,7 @@ namespace NetworkResurrector.Api.Application.Queries
|
||||||
var appDate = Environment.GetEnvironmentVariable("APP_DATE");
|
var appDate = Environment.GetEnvironmentVariable("APP_DATE");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(version))
|
if (string.IsNullOrEmpty(version))
|
||||||
version = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
|
version = Assembly.GetEntryAssembly().GetName().Version.ToString();
|
||||||
|
|
||||||
if (!DateTime.TryParse(appDate, out var lastReleaseDate))
|
if (!DateTime.TryParse(appDate, out var lastReleaseDate))
|
||||||
{
|
{
|
||||||
|
@ -60,7 +61,7 @@ namespace NetworkResurrector.Api.Application.Queries
|
||||||
lastReleaseDate = File.GetLastWriteTime(location);
|
lastReleaseDate = File.GetLastWriteTime(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = new ServiceVersion(version, lastReleaseDate);
|
var result = new ServiceVersion(FormatVersion(version), lastReleaseDate);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,6 +78,17 @@ namespace NetworkResurrector.Api.Application.Queries
|
||||||
}
|
}
|
||||||
return new ServiceVersion("0.0.0", DateTime.MinValue);
|
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.Constants;
|
||||||
using NetworkResurrector.Api.Domain.Models.Notifications;
|
using NetworkResurrector.Api.Domain.Models.ExternalNotifications;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ namespace NetworkResurrector.Api.Application.Services.Abstractions
|
||||||
{
|
{
|
||||||
public interface INotificationService
|
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 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);
|
Task NotifyError(string errorMessage, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||||
using NBB.Messaging.Abstractions;
|
using NBB.Messaging.Abstractions;
|
||||||
using NetworkResurrector.Api.Application.Services.Abstractions;
|
using NetworkResurrector.Api.Application.Services.Abstractions;
|
||||||
using NetworkResurrector.Api.Domain.Constants;
|
using NetworkResurrector.Api.Domain.Constants;
|
||||||
using NetworkResurrector.Api.Domain.Models.Notifications;
|
using NetworkResurrector.Api.Domain.Models.ExternalNotifications;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
@ -96,7 +96,7 @@ namespace NetworkResurrector.Api.Application.Services
|
||||||
return placeHolderValue;
|
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 notification = GetNotification(type, context);
|
||||||
var cmd = new SendEmail()
|
var cmd = new SendEmail()
|
||||||
|
@ -108,6 +108,9 @@ namespace NetworkResurrector.Api.Application.Services
|
||||||
};
|
};
|
||||||
|
|
||||||
await _messageBusPublisher.PublishAsync(cmd, cancellationToken);
|
await _messageBusPublisher.PublishAsync(cmd, cancellationToken);
|
||||||
|
|
||||||
|
var result = NotificationResult.Create(notification.To);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task NotifyError(string errorMessage, CancellationToken cancellationToken = default)
|
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
|
public record Notification
|
||||||
{
|
{
|
|
@ -1,4 +1,4 @@
|
||||||
namespace NetworkResurrector.Api.Domain.Models.Notifications
|
namespace NetworkResurrector.Api.Domain.Models.ExternalNotifications
|
||||||
{
|
{
|
||||||
public record NotificationContext
|
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
|
public record NotificationTemplate
|
||||||
{
|
{
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace NetworkResurrector.Api.Domain.Models.InternalNotifications
|
||||||
|
{
|
||||||
|
public record EmailSent : IRealtimeMessage
|
||||||
|
{
|
||||||
|
public string To { get; init; }
|
||||||
|
public string MachineName { get; init; }
|
||||||
|
public string Status { get; init; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace NetworkResurrector.Api.Domain.Models.InternalNotifications
|
||||||
|
{
|
||||||
|
public interface IRealtimeMessage { }
|
||||||
|
|
||||||
|
public record RealtimeNotification<TPayload> where TPayload : IRealtimeMessage
|
||||||
|
{
|
||||||
|
public string Type { get; init; }
|
||||||
|
public TPayload Payload { get; init; }
|
||||||
|
public string SourceId { get; init; }
|
||||||
|
|
||||||
|
public static RealtimeNotification<TPayload> Create(TPayload payload, string sourceId)
|
||||||
|
{
|
||||||
|
var type = typeof(TPayload).FullName ?? "UnknownType";
|
||||||
|
return new RealtimeNotification<TPayload>
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Payload = payload,
|
||||||
|
SourceId = sourceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NetworkResurrector.Api.Application.Services.Abstractions;
|
||||||
|
using NetworkResurrector.Api.Domain.Models.InternalNotifications;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NetworkResurrector.Api.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/realtime-notifications")]
|
||||||
|
public class RealtimeNotificationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMessageHubPublisher _messageHubPublisher;
|
||||||
|
private readonly ILogger<RealtimeNotificationsController> _logger;
|
||||||
|
|
||||||
|
public RealtimeNotificationsController(IMessageHubPublisher messageHubPublisher, ILogger<RealtimeNotificationsController> logger)
|
||||||
|
{
|
||||||
|
_messageHubPublisher = messageHubPublisher;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("broadcast")]
|
||||||
|
public async Task<IActionResult> Broadcast([FromBody] MessageModel message)
|
||||||
|
{
|
||||||
|
await _messageHubPublisher.Broadcast(message);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("message-to-client")]
|
||||||
|
public async Task<IActionResult> SendMessageToClient([FromBody] MessageModel message)
|
||||||
|
{
|
||||||
|
await _messageHubPublisher.Send(message);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("message-to-user")]
|
||||||
|
public async Task<IActionResult> SendMessageToUser([FromBody] MessageModel message)
|
||||||
|
{
|
||||||
|
await _messageHubPublisher.SendToUser(message);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MessageModel : IRealtimeMessage
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace NetworkResurrector.Api.Extensions
|
||||||
|
{
|
||||||
|
public static class HttpRequestExtensions
|
||||||
|
{
|
||||||
|
private const string SignalRConnectionIdHeader = "SignalR-ConnectionId";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the SignalR connection ID from the request headers if present
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The HTTP request</param>
|
||||||
|
/// <returns>The SignalR connection ID or null if not present</returns>
|
||||||
|
public static string GetSignalRConnectionId(this HttpRequest request)
|
||||||
|
{
|
||||||
|
if (request.Headers.TryGetValue(SignalRConnectionIdHeader, out var values))
|
||||||
|
{
|
||||||
|
return values.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
using NetworkResurrector.Api.Application.Services.Abstractions;
|
||||||
|
using NetworkResurrector.Api.Services;
|
||||||
|
using System;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace NetworkResurrector.Api.Extensions
|
||||||
|
{
|
||||||
|
public static class SignalRExtensions
|
||||||
|
{
|
||||||
|
private const string AUTH_QUERY_STRING_KEY = "access_token";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware that extracts a bearer token from the query string (using the "access_token" key)
|
||||||
|
/// and injects it into the Authorization header. This is necessary for SignalR connections,
|
||||||
|
/// which cannot send authentication tokens via headers during the WebSocket handshake.
|
||||||
|
/// Enables standard authentication handlers to process SignalR requests.
|
||||||
|
/// </summary>
|
||||||
|
public static void UseSignalRAuthentication(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var headers = context.Request.Headers;
|
||||||
|
if (string.IsNullOrWhiteSpace(headers[HeaderNames.Authorization]) &&
|
||||||
|
context.Request.Query.TryGetValue(AUTH_QUERY_STRING_KEY, out var token) &&
|
||||||
|
!string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Overwrite or set the Authorization header with the Bearer token from the query string
|
||||||
|
headers[HeaderNames.Authorization] = $"Tuitio {token}";
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Ignore if setting the header fails (e.g., if headers are read-only at this point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next.Invoke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the user identifier for the current connection
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">HubCallerContext</param>
|
||||||
|
/// <returns>The user id or null if not authenticated</returns>
|
||||||
|
public static string GetUserId(this HubCallerContext context)
|
||||||
|
{
|
||||||
|
return context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the username for the current connection
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">HubCallerContext</param>
|
||||||
|
/// <returns>The username or null if not authenticated</returns>
|
||||||
|
public static string GetUsername(this HubCallerContext context)
|
||||||
|
{
|
||||||
|
return context.User?.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AddSignalRNotifications(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSignalR();
|
||||||
|
services.AddScoped<IMessageHubPublisher, MessageHubPublisher>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,18 +13,22 @@ using NetworkResurrector.Agent.Wrapper;
|
||||||
using NetworkResurrector.Api.Application;
|
using NetworkResurrector.Api.Application;
|
||||||
using NetworkResurrector.Api.Authorization;
|
using NetworkResurrector.Api.Authorization;
|
||||||
using NetworkResurrector.Api.Domain.Data;
|
using NetworkResurrector.Api.Domain.Data;
|
||||||
|
using NetworkResurrector.Api.Hubs;
|
||||||
using NetworkResurrector.Server.Wrapper;
|
using NetworkResurrector.Server.Wrapper;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace NetworkResurrector.Api.Extensions
|
namespace NetworkResurrector.Api.Extensions
|
||||||
{
|
{
|
||||||
public static class StartupExtensions
|
public static class StartupExtensions
|
||||||
{
|
{
|
||||||
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)
|
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddControllers()
|
services.AddControllers()
|
||||||
.AddNewtonsoftJson(o => o.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc);
|
.AddNewtonsoftJson(o => o.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc);
|
||||||
|
|
||||||
|
// Add realtime notifications
|
||||||
|
services.AddSignalRNotifications();
|
||||||
|
|
||||||
// Add basic authentication
|
// Add basic authentication
|
||||||
services.AddTuitioAuthentication(configuration.GetSection("Tuitio")["BaseAddress"]);
|
services.AddTuitioAuthentication(configuration.GetSection("Tuitio")["BaseAddress"]);
|
||||||
|
|
||||||
|
@ -62,18 +66,24 @@ namespace NetworkResurrector.Api.Extensions
|
||||||
services.AddMessageBus(configuration);
|
services.AddMessageBus(configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Configure(this IApplicationBuilder app)
|
public static void Configure(this IApplicationBuilder app, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// global cors policy
|
var origins = configuration.GetSection("AllowedOrigins").Get<string[]>();
|
||||||
app.UseCors(x => x.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
|
app.UseCors(x => x
|
||||||
|
.WithOrigins(origins)
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials());
|
||||||
app.UseExceptionHandler("/error");
|
app.UseExceptionHandler("/error");
|
||||||
|
|
||||||
|
app.UseSignalRAuthentication();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseEndpoints(endpoints =>
|
app.UseEndpoints(endpoints =>
|
||||||
{
|
{
|
||||||
endpoints.MapControllers();
|
endpoints.MapControllers();
|
||||||
|
endpoints.MapHub<MessageHub>("/hubs/notifications");
|
||||||
});
|
});
|
||||||
app.ConfigureSwagger("NetworkResurrector API");
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.14" />
|
<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" Version="$(MicrosoftExtensionsPackageVersion)" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
<PackageReference Include="NBB.Messaging.Nats" Version="$(NBBPackageVersion)" />
|
<PackageReference Include="NBB.Messaging.Nats" Version="$(NBBPackageVersion)" />
|
||||||
|
|
|
@ -26,7 +26,7 @@ namespace NetworkResurrector.Api
|
||||||
builder.Services.ConfigureServices(builder.Configuration);
|
builder.Services.ConfigureServices(builder.Configuration);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.Configure();
|
app.Configure(builder.Configuration);
|
||||||
|
|
||||||
var exitCode = 0;
|
var exitCode = 0;
|
||||||
try
|
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": "*",
|
"AllowedHosts": "*",
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:3000"
|
||||||
|
],
|
||||||
"Service": {
|
"Service": {
|
||||||
"Code": "NETWORK_RESURRECTOR_API"
|
"Code": "NETWORK_RESURRECTOR_API"
|
||||||
},
|
},
|
||||||
|
|
|
@ -24,7 +24,7 @@ namespace NetworkResurrector.Server.Application.Queries
|
||||||
var appDate = Environment.GetEnvironmentVariable("APP_DATE");
|
var appDate = Environment.GetEnvironmentVariable("APP_DATE");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(version))
|
if (string.IsNullOrEmpty(version))
|
||||||
version = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
|
version = Assembly.GetEntryAssembly().GetName().Version.ToString();
|
||||||
|
|
||||||
if (!DateTime.TryParse(appDate, out var lastReleaseDate))
|
if (!DateTime.TryParse(appDate, out var lastReleaseDate))
|
||||||
{
|
{
|
||||||
|
|
|
@ -4,16 +4,16 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
-->
|
-->
|
||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||||
<PublishProvider>FileSystem</PublishProvider>
|
<PublishProvider>FileSystem</PublishProvider>
|
||||||
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
|
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
|
||||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||||
<SiteUrlToLaunchAfterPublish />
|
<SiteUrlToLaunchAfterPublish />
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<ProjectGuid>f6600491-5d79-4548-8745-59d9d337d3db</ProjectGuid>
|
<ProjectGuid>f6600491-5d79-4548-8745-59d9d337d3db</ProjectGuid>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|
|
@ -3,5 +3,4 @@ node_modules
|
||||||
build
|
build
|
||||||
__mocks__
|
__mocks__
|
||||||
.vscode
|
.vscode
|
||||||
helm
|
helm
|
||||||
private
|
|
|
@ -1,8 +1,11 @@
|
||||||
REACT_APP_TUITIO_URL=http://#######
|
VITE_APP_TUITIO_URL=https://<VITE_APP_TUITIO_URL>
|
||||||
REACT_APP_NETWORK_RESURRECTOR_API_URL=http://#######
|
VITE_APP_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
|
||||||
|
|
||||||
#600000 milliseconds = 10 minutes
|
#600000 milliseconds = 10 minutes
|
||||||
REACT_APP_MACHINE_PING_INTERVAL=600000
|
VITE_APP_MACHINE_PING_INTERVAL=600000
|
||||||
|
|
||||||
#300000 milliseconds = 5 minutes
|
#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=
|
VITE_APP_BASE_URL=
|
||||||
REACT_APP_TUITIO_URL=https://#######
|
VITE_APP_TUITIO_URL=https://<VITE_APP_TUITIO_URL>
|
||||||
REACT_APP_NETWORK_RESURRECTOR_API_URL=https://#######
|
VITE_APP_API_URL=https://<VITE_APP_NETWORK_RESURRECTOR_API_URL>
|
||||||
|
|
||||||
#900000 milliseconds = 15 minutes
|
#900000 milliseconds = 15 minutes
|
||||||
REACT_APP_MACHINE_PING_INTERVAL=900000
|
VITE_APP_MACHINE_PING_INTERVAL=900000
|
||||||
|
|
||||||
#300000 milliseconds = 5 minutes
|
#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
|
# production
|
||||||
/build
|
/build
|
||||||
|
/build2
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
"printWidth": 120,
|
"printWidth": 160,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"endOfLine": "auto"
|
"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
|
# BUILD ENVIRONMENT
|
||||||
FROM node:16-slim AS builder
|
FROM node:23-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG APP_SUBFOLDER=""
|
ARG APP_SUBFOLDER=""
|
||||||
|
@ -12,24 +12,26 @@ RUN rm -f .npmrc
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
# build the react app
|
# 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
|
# PRODUCTION ENVIRONMENT
|
||||||
FROM node:16-slim
|
FROM node:23-slim
|
||||||
|
|
||||||
ARG APP_SUBFOLDER=""
|
ARG APP_SUBFOLDER=""
|
||||||
|
|
||||||
RUN printf '\n\n- Copy application files\n'
|
RUN printf '\n\n- Copy application files\n'
|
||||||
COPY --from=builder /app/build ./application/${APP_SUBFOLDER}
|
COPY --from=builder /app/build ./application/${APP_SUBFOLDER}
|
||||||
COPY --from=builder /app/build/index.html ./application/
|
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
|
#install static server
|
||||||
RUN npm install -g serve
|
RUN npm install -g serve
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
ENV AUTHOR="Tudor Stanciu"
|
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
|
ARG APP_VERSION=0.0.0
|
||||||
ENV APP_VERSION=${APP_VERSION}
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
|
@ -41,4 +43,4 @@ WORKDIR /
|
||||||
|
|
||||||
EXPOSE 80
|
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",
|
"name": "network-resurrector-frontend",
|
||||||
"version": "1.3.1",
|
"version": "1.4.2",
|
||||||
"description": "Frontend component of Network resurrector system",
|
"description": "Frontend component of Network resurrector system",
|
||||||
|
"type": "module",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Tudor Stanciu",
|
"name": "Tudor Stanciu",
|
||||||
"email": "tudor.stanciu94@gmail.com",
|
"email": "tudor.stanciu94@gmail.com",
|
||||||
|
@ -13,54 +14,60 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@flare/js-utils": "^1.1.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@flare/tuitio-client-react": "^1.2.10",
|
"@flare/tuitio-client-react": "^1.3.0",
|
||||||
"@mui/icons-material": "^5.14.16",
|
"@flare/utiliyo": "^1.2.1",
|
||||||
"@mui/lab": "^5.0.0-alpha.169",
|
"@microsoft/signalr": "^8.0.7",
|
||||||
"@mui/material": "^5.14.16",
|
"@mui/icons-material": "^7.0.2",
|
||||||
"axios": "^1.6.8",
|
"@mui/lab": "^7.0.0-beta.11",
|
||||||
"i18next": "^22.4.15",
|
"@mui/material": "^7.0.2",
|
||||||
"i18next-browser-languagedetector": "^7.0.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"i18next-http-backend": "^2.2.0",
|
"axios": "^1.9.0",
|
||||||
|
"i18next": "^25.0.1",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.5",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.30.1",
|
||||||
"react": "^18.2.0",
|
"nanoid": "^5.1.5",
|
||||||
"react-dom": "^18.2.0",
|
"react": "^19.1.0",
|
||||||
"react-i18next": "^12.2.2",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^15.5.1",
|
||||||
"react-lazylog": "^4.5.3",
|
"react-lazylog": "^4.5.3",
|
||||||
"react-router-dom": "^6.10.0",
|
"react-router-dom": "^7.5.2",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^11.0.5",
|
||||||
"react-world-flags": "^1.6.0",
|
"react-world-flags": "^1.6.0",
|
||||||
"swr": "^2.2.5"
|
"swr": "^2.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@types/lodash": "^4.17.7",
|
"@eslint/js": "^9.25.1",
|
||||||
"@types/react": "^18.2.33",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/react-dom": "^18.2.14",
|
"@types/node": "^22.15.2",
|
||||||
"@types/react-world-flags": "^1.4.5",
|
"@types/react": "^19.1.2",
|
||||||
"eslint": "^8.34.0",
|
"@types/react-dom": "^19.1.2",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"@types/react-world-flags": "^1.6.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint": "^9.25.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"prettier": "^2.8.4",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"react-app-rewired": "^2.2.1",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"typescript": "^4.9.5"
|
"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": {
|
"scripts": {
|
||||||
"start": "react-app-rewired start",
|
"start": "vite",
|
||||||
"build": "react-app-rewired build",
|
"build": "tsc && vite build",
|
||||||
"test": "react-app-rewired test",
|
"lint": "eslint .",
|
||||||
"eject": "react-app-rewired eject"
|
"preview": "vite preview",
|
||||||
},
|
"test": "jest"
|
||||||
"eslintConfig": {
|
|
||||||
"extends": [
|
|
||||||
"react-app",
|
|
||||||
"react-app/jest"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"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",
|
"Dashboard": "Dashboard",
|
||||||
"Machines": "Machines",
|
"Machines": "Machines",
|
||||||
"System": "System",
|
"System": "System",
|
||||||
"Administration": "Administration",
|
"Administration": { "Title": "Administration", "Machines": "Machines", "Agents": "Agents" },
|
||||||
"Settings": "Settings",
|
"Settings": "Settings",
|
||||||
|
"Debugging": {
|
||||||
|
"Title": "Debugging",
|
||||||
|
"Notifications": "Notifications"
|
||||||
|
},
|
||||||
"About": "About"
|
"About": "About"
|
||||||
},
|
},
|
||||||
"ViewModes": {
|
"ViewModes": {
|
||||||
|
|
|
@ -13,8 +13,12 @@
|
||||||
"Dashboard": "Bord",
|
"Dashboard": "Bord",
|
||||||
"Machines": "Mașini",
|
"Machines": "Mașini",
|
||||||
"System": "Sistem",
|
"System": "Sistem",
|
||||||
"Administration": "Administrare",
|
"Administration": { "Title": "Administrare", "Machines": "Mașini", "Agents": "Agenți" },
|
||||||
"Settings": "Setări",
|
"Settings": "Setări",
|
||||||
|
"Debugging": {
|
||||||
|
"Title": "Depanare",
|
||||||
|
"Notifications": "Notificări"
|
||||||
|
},
|
||||||
"About": "Despre"
|
"About": "Despre"
|
||||||
},
|
},
|
||||||
"ViewModes": {
|
"ViewModes": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"short_name": "React App",
|
"short_name": "React App",
|
||||||
"name": "Create React App Sample",
|
"name": "Vite React App",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"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 React from "react";
|
||||||
import { UserPermissionsProvider, SensitiveInfoProvider } from "../providers";
|
import { SensitiveInfoProvider } from "../providers";
|
||||||
|
import { UserPermissionsProvider } from "../units/permissions";
|
||||||
import AppLayout from "./layout/AppLayout";
|
import AppLayout from "./layout/AppLayout";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
|
@ -3,8 +3,9 @@ import App from "./App";
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { useTuitioToken } from "@flare/tuitio-client-react";
|
import { useTuitioToken } from "@flare/tuitio-client-react";
|
||||||
import LoginContainer from "../features/login/components/LoginContainer";
|
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 { valid } = useTuitioToken();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return valid ? (
|
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 location = useLocation();
|
||||||
const { valid } = useTuitioToken();
|
const { valid } = useTuitioToken();
|
||||||
const to = useMemo(() => {
|
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 = () => {
|
const AppRouter: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter basename={process.env.PUBLIC_URL || ""}>
|
<BrowserRouter basename={baseName}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||||
<Route
|
<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 React from "react";
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import { Typography, Box } from "@mui/material";
|
import { Typography, Box } from "@mui/material";
|
||||||
|
|
||||||
const styles = {
|
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 (
|
return (
|
||||||
<Box sx={styles.box}>
|
<Box sx={styles.box}>
|
||||||
{navigation && navigation}
|
{navigation && navigation}
|
||||||
<Box sx={styles.title}>
|
<Box sx={styles.title}>
|
||||||
<Typography sx={styles.titleText} variant="h3" size="sm">
|
<Typography sx={styles.titleText} variant="h3">
|
||||||
{text}
|
{text}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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;
|
export default PageTitle;
|
|
@ -1,5 +1,6 @@
|
||||||
import DataLabel from "./DataLabel";
|
import DataLabel from "./DataLabel";
|
||||||
import PaperTitle from "./PaperTitle";
|
import PaperTitle from "./PaperTitle";
|
||||||
import FlagIcon from "./FlagIcon";
|
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 {
|
interface Props extends IconProps {
|
||||||
code?: string | null;
|
code?: string | null;
|
||||||
fallback?: JSX.Element;
|
fallback?: React.ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DynamicIcon: React.FC<Props> = ({ code, fallback, ...res }) => {
|
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 DashboardContainer from "../../features/dashboard/DashboardContainer";
|
||||||
import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer";
|
import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer";
|
||||||
import AboutContainer from "../../features/about/AboutContainer";
|
import AboutContainer from "../../features/about/AboutContainer";
|
||||||
|
import NotificationDemo from "features/debugging/notifications/NotificationDemo";
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppRoutes: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -16,6 +17,7 @@ const AppRoutes: React.FC = () => {
|
||||||
<Route path="/machines" element={<NetworkContainer />} />
|
<Route path="/machines" element={<NetworkContainer />} />
|
||||||
<Route path="/system" element={<SystemContainer />} />
|
<Route path="/system" element={<SystemContainer />} />
|
||||||
<Route path="/settings" element={<SettingsContainer />} />
|
<Route path="/settings" element={<SettingsContainer />} />
|
||||||
|
<Route path="/debugging/notifications" element={<NotificationDemo />} />
|
||||||
<Route path="/about" element={<AboutContainer />} />
|
<Route path="/about" element={<AboutContainer />} />
|
||||||
<Route path="/*" element={<PageNotFound />} />
|
<Route path="/*" element={<PageNotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconButton } from "@mui/material";
|
import { IconButton } from "@mui/material";
|
||||||
import { Brightness2 as MoonIcon, WbSunny as SunIcon } from "@mui/icons-material";
|
import { Brightness2 as MoonIcon, WbSunny as SunIcon } from "@mui/icons-material";
|
||||||
import { useApplicationTheme } from "../../providers/ThemeProvider";
|
import { useApplicationTheme } from "../../hooks";
|
||||||
|
|
||||||
const LightDarkToggle = () => {
|
const LightDarkToggle = () => {
|
||||||
const { isDark, onDarkModeChanged } = useApplicationTheme();
|
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 { styled, Theme, CSSObject } from "@mui/material/styles";
|
||||||
import MuiDrawer from "@mui/material/Drawer";
|
import MuiDrawer from "@mui/material/Drawer";
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
|
@ -67,8 +67,6 @@ const SideBar: React.FC<SideBarProps> = ({ open, onDrawerOpen, onDrawerClose })
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
menu.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer variant="permanent" open={open}>
|
<Drawer variant="permanent" open={open}>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import SensitiveInfoToggle from "./SensitiveInfoToggle";
|
||||||
import { styled } from "@mui/material/styles";
|
import { styled } from "@mui/material/styles";
|
||||||
import { drawerWidth } from "./constants";
|
import { drawerWidth } from "./constants";
|
||||||
import { ProgressBar } from "units/progress";
|
import { ProgressBar } from "units/progress";
|
||||||
|
import AppNotifications from "./notifications/AppNotifications";
|
||||||
|
|
||||||
interface AppBarProps extends MuiAppBarProps {
|
interface AppBarProps extends MuiAppBarProps {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
@ -60,6 +61,7 @@ const TopBar: React.FC<TopBarProps> = ({ open, onDrawerOpen }) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
<Box sx={{ display: { xs: "none", md: "flex" }, gap: 1 }}>
|
<Box sx={{ display: { xs: "none", md: "flex" }, gap: 1 }}>
|
||||||
|
<AppNotifications />
|
||||||
<SensitiveInfoToggle />
|
<SensitiveInfoToggle />
|
||||||
<LightDarkToggle />
|
<LightDarkToggle />
|
||||||
<ProfileButton />
|
<ProfileButton />
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./menu";
|
import menu from "./menu";
|
||||||
|
|
||||||
export const drawerWidth = 240;
|
export const drawerWidth = 240;
|
||||||
|
export { menu };
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import React from "react";
|
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 = {
|
type MenuItem = {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
route: string;
|
route: string;
|
||||||
icon: JSX.Element;
|
icon: React.ReactElement;
|
||||||
order: number;
|
order: number;
|
||||||
subMenus?: MenuItem[];
|
subMenus?: MenuItem[];
|
||||||
|
hidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MenuSection = {
|
type MenuSection = {
|
||||||
|
@ -49,23 +53,23 @@ const menu: Menu = [
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
code: "administration",
|
code: "administration",
|
||||||
name: "Menu.Administration",
|
name: "Menu.Administration.Title",
|
||||||
route: "/administration",
|
route: "/administration",
|
||||||
icon: <Build />,
|
icon: <Build />,
|
||||||
order: 0,
|
order: 0,
|
||||||
subMenus: [
|
subMenus: [
|
||||||
{
|
{
|
||||||
code: "machines",
|
code: "machines",
|
||||||
name: "Menu.Machines",
|
name: "Menu.Administration.Machines",
|
||||||
route: "/administration/machines",
|
route: "/administration/machines",
|
||||||
icon: <Build />,
|
icon: <Devices />,
|
||||||
order: 0
|
order: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "agents",
|
code: "agents",
|
||||||
name: "Menu.Agents",
|
name: "Menu.Administration.Agents",
|
||||||
route: "/administration/agents",
|
route: "/administration/agents",
|
||||||
icon: <Build />,
|
icon: <Stream />,
|
||||||
order: 1
|
order: 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -76,6 +80,23 @@ const menu: Menu = [
|
||||||
route: "/settings",
|
route: "/settings",
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
order: 1
|
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 type { MenuItem, MenuSection, Menu };
|
||||||
export { menu };
|
export default filteredMenu;
|
||||||
export default menu;
|
|
||||||
|
|
|
@ -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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container>
|
<Grid container flexGrow={1}>
|
||||||
<Grid item xs={6} sm={2} md={2}>
|
<Grid size={{ xs: 12, sm: 2 }}>
|
||||||
<Typography variant={collapsed ? "subtitle2" : "h6"}>
|
<Typography variant={collapsed ? "subtitle2" : "h6"}>
|
||||||
{`${t("About.ReleaseNotes.Version")}: ${releaseNote.version}`}
|
{`${t("About.ReleaseNotes.Version")}: ${releaseNote.version}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</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"}>
|
<Typography variant={collapsed ? "subtitle2" : "h6"}>
|
||||||
{`${t("About.ReleaseNotes.Date")}: ${t("DATE_FORMAT", {
|
{`${t("About.ReleaseNotes.Date")}: ${t("DATE_FORMAT", {
|
||||||
date: { value: releaseNote.date, format: "DD-MM-YYYY HH:mm" }
|
date: { value: releaseNote.date, format: "DD-MM-YYYY HH:mm" }
|
||||||
|
@ -21,7 +21,7 @@ const ReleaseNoteSummary = ({ releaseNote, collapsed }) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<Grid item xs={12} sm={8} md={8}>
|
<Grid size={{ xs: 12, sm: 8, md: 6 }}>
|
||||||
<Typography variant="body2">{releaseNote.notes[0]}</Typography>
|
<Typography variant="body2">{releaseNote.notes[0]}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
|
@ -43,7 +43,7 @@ const SystemVersionComponent: React.FC<Props> = ({ data }) => {
|
||||||
|
|
||||||
const frontend = t("DATE_FORMAT", {
|
const frontend = t("DATE_FORMAT", {
|
||||||
date: {
|
date: {
|
||||||
value: process.env.APP_DATE ?? new Date(),
|
value: import.meta.env.APP_DATE ?? new Date(),
|
||||||
format
|
format
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -109,7 +109,7 @@ const SystemVersionComponent: React.FC<Props> = ({ data }) => {
|
||||||
primary={
|
primary={
|
||||||
<VersionLabel>
|
<VersionLabel>
|
||||||
{t("About.System.Version.Frontend", {
|
{t("About.System.Version.Frontend", {
|
||||||
version: process.env.APP_VERSION ?? packageData.version
|
version: import.meta.env.APP_VERSION ?? packageData.version
|
||||||
})}
|
})}
|
||||||
</VersionLabel>
|
</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 GridCell: React.FC<GridCellProps> = ({ label, value }) => {
|
||||||
const { mask } = useSensitiveInfo();
|
const { mask } = useSensitiveInfo();
|
||||||
return (
|
return (
|
||||||
<Grid item xs={12} md={6} lg={3}>
|
<Grid size={{ xs: 12, md: 6, lg: 3 }}>
|
||||||
<DataLabel label={label} data={mask(value)} />
|
<DataLabel label={label} data={mask(value)} />
|
||||||
</Grid>
|
</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}`}>
|
<AccordionSummary aria-controls={`machine-${machine.machineId}-summary`} id={`machine-${machine.machineId}`}>
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
|
flex={1}
|
||||||
sx={{
|
sx={{
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center"
|
alignItems: "center"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Grid item xs={11}>
|
<Grid size={11}>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<GridCell label={t("Machine.FullName")} value={machine.fullMachineName} />
|
<GridCell label={t("Machine.FullName")} value={machine.fullMachineName} />
|
||||||
<GridCell label={t("Machine.Name")} value={machine.machineName} />
|
<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} />
|
<GridCell label={t("Machine.MAC")} value={machine.macAddress} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={1} style={{ textAlign: "right" }}>
|
<Grid size={1} style={{ textAlign: "right" }}>
|
||||||
<ActionsGroup machine={machine} actions={actions} addLog={addLog} />
|
<ActionsGroup machine={machine} actions={actions} addLog={addLog} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import MachineTableRow from "./MachineTableRow";
|
import MachineTableRow from "./MachineTableRow";
|
||||||
import MachineAccordion from "./MachineAccordion";
|
import MachineAccordion from "./MachineAccordion";
|
||||||
import { ViewModes } from "./ViewModeSelection";
|
import { ViewModes } from "../constants";
|
||||||
import { blip } from "../../../utils";
|
import { blip } from "../../../utils";
|
||||||
import { LastPage, RotateLeft, Launch, Stop } from "@mui/icons-material";
|
import { LastPage, RotateLeft, Launch, Stop } from "@mui/icons-material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { NetworkStateContext, NetworkDispatchContext } from "../../network/state
|
||||||
import MachinesListComponent from "./MachinesListComponent";
|
import MachinesListComponent from "./MachinesListComponent";
|
||||||
import PageTitle from "../../../components/common/PageTitle";
|
import PageTitle from "../../../components/common/PageTitle";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ViewModeSelection, { ViewModes } from "./ViewModeSelection";
|
import ViewModeSelection from "./ViewModeSelection";
|
||||||
|
import { ViewModes } from "../constants";
|
||||||
import { endpoints } from "../../../utils/api";
|
import { endpoints } from "../../../utils/api";
|
||||||
import { useSWR, fetcher } from "units/swr";
|
import { useSWR, fetcher } from "units/swr";
|
||||||
import { blip } from "utils";
|
import { blip } from "utils";
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from
|
||||||
import Paper from "@mui/material/Paper";
|
import Paper from "@mui/material/Paper";
|
||||||
import MachineContainer from "./MachineContainer";
|
import MachineContainer from "./MachineContainer";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ViewModes } from "./ViewModeSelection";
|
import { ViewModes } from "../constants";
|
||||||
|
|
||||||
const MachinesList = ({ machines, viewMode }) => {
|
const MachinesList = ({ machines, viewMode }) => {
|
||||||
return (
|
return (
|
|
@ -5,11 +5,7 @@ import ViewListIcon from "@mui/icons-material/ViewList";
|
||||||
import { ToggleButtonGroup, ToggleButton } from "@mui/material";
|
import { ToggleButtonGroup, ToggleButton } from "@mui/material";
|
||||||
import { Tooltip } from "@mui/material";
|
import { Tooltip } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ViewModes } from "../constants";
|
||||||
export const ViewModes = {
|
|
||||||
TABLE: "table",
|
|
||||||
ACCORDION: "accordion"
|
|
||||||
};
|
|
||||||
|
|
||||||
const ViewModeSelection = ({ initialMode, callback }) => {
|
const ViewModeSelection = ({ initialMode, callback }) => {
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
|
@ -2,7 +2,7 @@ import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { IconButton, Tooltip } from "@mui/material";
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
|
|
||||||
const ActionButton = React.forwardRef(props => {
|
const ActionButton = React.forwardRef((props, ref) => {
|
||||||
const { action, machine, callback, disabled } = props;
|
const { action, machine, callback, disabled } = props;
|
||||||
const id = `machine-item-${machine.machineId}-${action.code}`;
|
const id = `machine-item-${machine.machineId}-${action.code}`;
|
||||||
const handleActionClick = event => {
|
const handleActionClick = event => {
|
||||||
|
@ -21,6 +21,7 @@ const ActionButton = React.forwardRef(props => {
|
||||||
onFocus={event => event.stopPropagation()}
|
onFocus={event => event.stopPropagation()}
|
||||||
onClick={handleActionClick}
|
onClick={handleActionClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<action.icon />
|
<action.icon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -36,7 +37,8 @@ ActionButton.propTypes = {
|
||||||
action: PropTypes.shape({
|
action: PropTypes.shape({
|
||||||
code: PropTypes.string.isRequired,
|
code: PropTypes.string.isRequired,
|
||||||
tooltip: PropTypes.string.isRequired,
|
tooltip: PropTypes.string.isRequired,
|
||||||
effect: PropTypes.func.isRequired
|
effect: PropTypes.func.isRequired,
|
||||||
|
icon: PropTypes.elementType.isRequired
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
callback: PropTypes.func,
|
callback: PropTypes.func,
|
||||||
disabled: PropTypes.bool
|
disabled: PropTypes.bool
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue