diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 0e35078..96652e6 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -79,6 +79,9 @@ + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor new file mode 100644 index 0000000..3b344a4 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor @@ -0,0 +1,483 @@ +@page "/monitoring/notification-outbox" +@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Messages.Notification +@using ScadaLink.Communication +@inject CommunicationService CommunicationService +@inject ISiteRepository SiteRepository +@inject IDialogService Dialog +@inject ILogger Logger + +
+ + +
+

Notification Outbox

+ +
+ + @* ── KPI tiles ── *@ + @if (_kpiError != null) + { +
KPIs unavailable: @_kpiError
+ } + else + { +
+
+
+
+

@_kpi.QueueDepth

+ Queue Depth +
+
+
+
+
+
+

@_kpi.StuckCount

+ Stuck +
+
+
+
+
+
+

@_kpi.ParkedCount

+ Parked +
+
+
+
+
+
+

@_kpi.DeliveredLastInterval

+ Delivered (last interval) +
+
+
+
+
+
+

@FormatAge(_kpi.OldestPendingAge)

+ Oldest Pending Age +
+
+
+
+ } + + @* ── Filters ── *@ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ + @if (_listError != null) + { +
@_listError
+ } + + @* ── Notification list ── *@ + @if (_notifications == null) + { + @if (_loading) + { +
Loading…
+ } + } + else if (_notifications.Count == 0) + { +
+
+
No notifications
+
No notifications match the current filters.
+
+
+ } + else + { +
+ + + + + + + + + + + + + + + + + @foreach (var n in _notifications) + { + + + + + + + + + + + + + } + +
IDTypeListSubjectStatusRetriesSource siteCreatedDeliveredActions
@ShortId(n.NotificationId)@n.Type@n.ListName + @n.Subject + @if (!string.IsNullOrEmpty(n.LastError)) + { +
@n.LastError
+ } +
+ @n.Status + @if (n.IsStuck) + { + Stuck + } + @n.RetryCount@SiteName(n.SourceSiteId) + @if (n.Status == "Parked") + { + + + } + else + { + + } +
+
+ + @if (_totalCount > _pageSize) + { +
+ + Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total + +
+ + +
+
+ } + } +
+ +@code { + private const int _pageSize = 50; + + private ToastNotification _toast = default!; + private List _sites = new(); + + // KPIs + private NotificationKpiResponse _kpi = + new(string.Empty, true, null, 0, 0, 0, 0, null); + private string? _kpiError; + + // List + private List? _notifications; + private int _totalCount; + private int _pageNumber = 1; + private bool _loading; + private string? _listError; + private bool _actionInProgress; + + // Filters + private string _statusFilter = string.Empty; + private string _typeFilter = string.Empty; + private string _siteFilter = string.Empty; + private string _listFilter = string.Empty; + private string _subjectFilter = string.Empty; + private bool _stuckOnly; + private DateTime? _fromFilter; + private DateTime? _toFilter; + + protected override async Task OnInitializedAsync() + { + try + { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + } + catch (Exception ex) + { + // Non-fatal — source-site filter just falls back to the raw site IDs. + Logger.LogWarning(ex, "Failed to load sites for the outbox source-site filter."); + } + + await RefreshAll(); + } + + private async Task RefreshAll() + { + await Task.WhenAll(LoadKpis(), FetchPage()); + } + + private async Task LoadKpis() + { + try + { + var response = await CommunicationService.GetNotificationKpisAsync( + new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); + if (response.Success) + { + _kpi = response; + _kpiError = null; + } + else + { + _kpiError = response.ErrorMessage ?? "KPI query failed."; + } + } + catch (Exception ex) + { + _kpiError = $"KPI query failed: {ex.Message}"; + } + } + + private async Task Search() + { + _pageNumber = 1; + await FetchPage(); + } + + private async Task PrevPage() { _pageNumber--; await FetchPage(); } + private async Task NextPage() { _pageNumber++; await FetchPage(); } + + private async Task FetchPage() + { + _loading = true; + _listError = null; + try + { + var request = new NotificationOutboxQueryRequest( + CorrelationId: Guid.NewGuid().ToString("N"), + StatusFilter: NullIfEmpty(_statusFilter), + TypeFilter: NullIfEmpty(_typeFilter), + SourceSiteFilter: NullIfEmpty(_siteFilter), + ListNameFilter: NullIfEmpty(_listFilter), + StuckOnly: _stuckOnly, + SubjectKeyword: NullIfEmpty(_subjectFilter), + From: ToUtc(_fromFilter), + To: ToUtc(_toFilter), + PageNumber: _pageNumber, + PageSize: _pageSize); + + var response = await CommunicationService.QueryNotificationOutboxAsync(request); + if (response.Success) + { + _notifications = response.Notifications.ToList(); + _totalCount = response.TotalCount; + } + else + { + _listError = response.ErrorMessage ?? "Query failed."; + } + } + catch (Exception ex) + { + _listError = $"Query failed: {ex.Message}"; + } + _loading = false; + } + + private async Task RetryNotification(NotificationSummary n) + { + var confirmed = await Dialog.ConfirmAsync( + "Retry notification", + $"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?"); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var response = await CommunicationService.RetryNotificationAsync( + new RetryNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId)); + if (response.Success) + { + _toast.ShowSuccess($"Notification {ShortId(n.NotificationId)} re-queued for delivery."); + await RefreshAll(); + } + else + { + _toast.ShowError(response.ErrorMessage ?? "Retry failed."); + } + } + catch (Exception ex) + { + _toast.ShowError($"Retry failed: {ex.Message}"); + } + _actionInProgress = false; + } + + private async Task DiscardNotification(NotificationSummary n) + { + var confirmed = await Dialog.ConfirmAsync( + "Discard notification", + $"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.", + danger: true); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var response = await CommunicationService.DiscardNotificationAsync( + new DiscardNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId)); + if (response.Success) + { + _toast.ShowSuccess($"Notification {ShortId(n.NotificationId)} discarded."); + await RefreshAll(); + } + else + { + _toast.ShowError(response.ErrorMessage ?? "Discard failed."); + } + } + catch (Exception ex) + { + _toast.ShowError($"Discard failed: {ex.Message}"); + } + _actionInProgress = false; + } + + private void ClearFilters() + { + _statusFilter = string.Empty; + _typeFilter = string.Empty; + _siteFilter = string.Empty; + _listFilter = string.Empty; + _subjectFilter = string.Empty; + _stuckOnly = false; + _fromFilter = null; + _toFilter = null; + } + + private bool HasActiveFilters => + !string.IsNullOrEmpty(_statusFilter) || + !string.IsNullOrEmpty(_typeFilter) || + !string.IsNullOrEmpty(_siteFilter) || + !string.IsNullOrEmpty(_listFilter) || + !string.IsNullOrEmpty(_subjectFilter) || + _stuckOnly || + _fromFilter != null || + _toFilter != null; + + private string SiteName(string siteId) => + _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId; + + private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + private static DateTimeOffset? ToUtc(DateTime? local) => + local == null ? null : new DateTimeOffset(DateTime.SpecifyKind(local.Value, DateTimeKind.Utc)); + + private static string ShortId(string id) => id[..Math.Min(12, id.Length)]; + + private static string FormatAge(TimeSpan? age) + { + if (age == null) return "—"; + var t = age.Value; + if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s"; + if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m"; + if (t.TotalHours < 24) return $"{(int)t.TotalHours}h"; + return $"{(int)t.TotalDays}d"; + } + + private static string StatusBadgeClass(string status) => status switch + { + "Delivered" => "bg-success", + "Parked" => "bg-danger", + "Retrying" => "bg-warning text-dark", + "Pending" => "bg-info text-dark", + "Discarded" => "bg-secondary", + _ => "bg-light text-dark" + }; +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs new file mode 100644 index 0000000..15c4553 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs @@ -0,0 +1,222 @@ +using System.Security.Claims; +using Akka.Actor; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.CentralUI.Components.Shared; +using NSubstitute; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Notification; +using ScadaLink.Communication; +using ScadaLink.Security; +using NotificationOutboxPage = ScadaLink.CentralUI.Components.Pages.Monitoring.NotificationOutbox; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// bUnit rendering tests for the Notification Outbox monitoring page (Task 23). +/// +/// Testability note: is a concrete class with +/// non-virtual methods, so NSubstitute cannot intercept it. The outbox calls all +/// route through an injected (the notification-outbox +/// proxy), so the tests wire a real, lightweight with a +/// scripted that replies with fixed responses — the +/// same seam SetNotificationOutbox exists for. +/// +public class NotificationOutboxPageTests : BunitContext +{ + private readonly ActorSystem _system = ActorSystem.Create("notif-outbox-tests"); + private readonly CommunicationService _comms; + + // Mutable scripted replies — individual tests can override before rendering. + private NotificationKpiResponse _kpiReply = + new("k", true, null, QueueDepth: 7, StuckCount: 2, ParkedCount: 1, + DeliveredLastInterval: 42, OldestPendingAge: TimeSpan.FromMinutes(9)); + + private NotificationOutboxQueryResponse _queryReply = + new("q", true, null, new List + { + new("notif-aaaaaaaa-1111", "Email", "Ops On-Call", "Pump fault at Plant-A", + "Parked", RetryCount: 3, LastError: "SMTP timeout", SourceSiteId: "plant-a", + SourceInstanceId: "Pump-001", CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30), + DeliveredAt: null, IsStuck: true), + new("notif-bbbbbbbb-2222", "Email", "Maintenance", "Daily summary", + "Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b", + SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2), + DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false), + }, TotalCount: 2); + + // Records the most recent retry/discard requests the actor received. + private readonly List _retryRequests = new(); + private readonly List _discardRequests = new(); + + public NotificationOutboxPageTests() + { + _comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + + var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); + _comms.SetNotificationOutbox(outbox); + + Services.AddSingleton(_comms); + Services.AddSingleton(new AlwaysConfirmDialogService()); + + var siteRepo = Substitute.For(); + siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List + { + new("Plant A", "plant-a") { Id = 1 }, + new("Plant B", "plant-b") { Id = 2 }, + })); + Services.AddSingleton(siteRepo); + + var claims = new[] + { + new Claim("Username", "tester"), + new Claim(ClaimTypes.Role, "Deployment"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + [Fact] + public void Page_RequiresDeploymentPolicy() + { + var attr = typeof(NotificationOutboxPage) + .GetCustomAttributes(typeof(AuthorizeAttribute), true) + .Cast() + .FirstOrDefault(); + + Assert.NotNull(attr); + Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); + } + + [Fact] + public void Renders_KpiTiles_WithValues() + { + var cut = Render(); + + // KPI data arrives via an async actor Ask after first render. + cut.WaitForAssertion(() => + { + Assert.Contains("Queue Depth", cut.Markup); + Assert.Contains("Stuck", cut.Markup); + Assert.Contains("Parked", cut.Markup); + Assert.Contains("Delivered", cut.Markup); + // KPI numeric values surface in the tiles. + Assert.Contains(">7<", cut.Markup); // QueueDepth + Assert.Contains(">42<", cut.Markup); // DeliveredLastInterval + }); + } + + [Fact] + public void Renders_NotificationRows() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("Pump fault at Plant-A", cut.Markup); + Assert.Contains("Daily summary", cut.Markup); + Assert.Contains("Ops On-Call", cut.Markup); + }); + } + + [Fact] + public void StuckRow_IsBadged() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + var stuckRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("Pump fault at Plant-A")); + + // The stuck row carries a visible "Stuck" badge. + Assert.Contains("badge", stuckRow.InnerHtml); + Assert.Contains("Stuck", stuckRow.TextContent); + }); + } + + [Fact] + public void ClickRetry_OnParkedRow_CallsRetryNotification() + { + var cut = Render(); + + cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A")); + + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("Pump fault at Plant-A")); + var retryButton = parkedRow.QuerySelectorAll("button") + .First(b => b.TextContent.Contains("Retry")); + + retryButton.Click(); + + cut.WaitForAssertion(() => + { + Assert.Single(_retryRequests); + Assert.Equal("notif-aaaaaaaa-1111", _retryRequests[0].NotificationId); + }); + } + + [Fact] + public void KpiFailure_ShowsErrorMessage() + { + _kpiReply = new NotificationKpiResponse( + "k", false, "outbox repository unavailable", 0, 0, 0, 0, null); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Contains("outbox repository unavailable", cut.Markup)); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _system.Terminate().Wait(TimeSpan.FromSeconds(5)); + } + base.Dispose(disposing); + } + + /// + /// Stand-in for the notification-outbox actor. Replies to each outbox message + /// type with the test's currently-scripted response. + /// + private sealed class ScriptedOutboxActor : ReceiveActor + { + public ScriptedOutboxActor(NotificationOutboxPageTests test) + { + Receive(_ => Sender.Tell(test._kpiReply)); + Receive(_ => Sender.Tell(test._queryReply)); + Receive(r => + { + test._retryRequests.Add(r); + Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)); + }); + Receive(r => + { + test._discardRequests.Add(r); + Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)); + }); + } + } + + /// A dialog service that auto-confirms, so action paths run end-to-end. + private sealed class AlwaysConfirmDialogService : IDialogService + { + public Task ConfirmAsync(string title, string message, bool danger = false) + => Task.FromResult(true); + + public Task PromptAsync( + string title, string label, string initialValue = "", string? placeholder = null) + => Task.FromResult(null); + } +}