From 9e7bc7b541834ed6a3ec48cb0f18dc9df29d7f65 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 03:05:41 -0400 Subject: [PATCH] feat(notification-outbox): add outbox KPI tiles to Health dashboard --- .../Components/Pages/Monitoring/Health.razor | 81 +++++++++++- .../Pages/HealthPageTests.cs | 124 ++++++++++++++++++ 2 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index 4d829cc..bc3d123 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -4,9 +4,12 @@ @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.HealthMonitoring +@using ScadaLink.Commons.Messages.Notification +@using ScadaLink.Communication @implements IDisposable @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository +@inject CommunicationService CommunicationService
@@ -17,6 +20,39 @@
+ @* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@ +
Notification Outbox
+
+
+
+
+

@OutboxTileValue(_outboxKpi.QueueDepth)

+ Queue Depth +
+
+
+
+
+
+

@OutboxTileValue(_outboxKpi.StuckCount)

+ Stuck +
+
+
+
+
+
+

@OutboxTileValue(_outboxKpi.ParkedCount)

+ Parked +
+
+
+
+ @if (!_outboxKpiAvailable && _outboxKpiError != null) + { +
Notification Outbox KPIs unavailable: @_outboxKpiError
+ } + @if (_siteStates.Count == 0) {
No site health reports received yet.
@@ -294,6 +330,12 @@ private Timer? _refreshTimer; private int _autoRefreshSeconds = 10; + // Notification Outbox headline KPIs, refreshed alongside the site states. + private NotificationKpiResponse _outboxKpi = + new(string.Empty, true, null, 0, 0, 0, 0, null); + private bool _outboxKpiAvailable; + private string? _outboxKpiError; + private static bool SiteHasActiveErrors(SiteHealthState state) { var report = state.LatestReport; @@ -316,22 +358,53 @@ // Non-fatal — fall back to showing siteId only } - RefreshNow(); + await RefreshNow(); _refreshTimer = new Timer(_ => { - InvokeAsync(() => + InvokeAsync(async () => { - RefreshNow(); + await RefreshNow(); StateHasChanged(); }); }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); } - private void RefreshNow() + private async Task RefreshNow() { _siteStates = HealthAggregator.GetAllSiteStates(); + await LoadOutboxKpis(); } + private async Task LoadOutboxKpis() + { + try + { + var response = await CommunicationService.GetNotificationKpisAsync( + new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); + if (response.Success) + { + _outboxKpi = response; + _outboxKpiAvailable = true; + _outboxKpiError = null; + } + else + { + _outboxKpiAvailable = false; + _outboxKpiError = response.ErrorMessage ?? "KPI query failed."; + } + } + catch (Exception ex) + { + _outboxKpiAvailable = false; + _outboxKpiError = $"KPI query failed: {ex.Message}"; + } + } + + // Tiles show the numeric KPI when available, or an em dash when the outbox + // KPI query failed — matching how the page renders other unavailable data. + private string OutboxTileValue(int value) => + _outboxKpiAvailable ? value.ToString() : "—"; + private string GetSiteName(string siteId) { return _siteNames.GetValueOrDefault(siteId, siteId); diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs new file mode 100644 index 0000000..0d6eb13 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs @@ -0,0 +1,124 @@ +using System.Security.Claims; +using Akka.Actor; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Notification; +using ScadaLink.Communication; +using ScadaLink.HealthMonitoring; +using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// bUnit rendering tests for the Health Monitoring dashboard (Task 24). +/// +/// Scope: the Notification Outbox KPI tile row added to the Health dashboard. +/// is an interface (mockable), but +/// is a concrete class whose outbox calls +/// route through an injected notification-outbox ; the +/// tests reuse the scripted-actor seam established by the Notification Outbox +/// page tests (see NotificationOutboxPageTests). +/// +public class HealthPageTests : BunitContext +{ + private readonly ActorSystem _system = ActorSystem.Create("health-page-tests"); + private readonly CommunicationService _comms; + + // Mutable scripted reply — individual tests can override before rendering. + private NotificationKpiResponse _kpiReply = + new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3, + DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6)); + + public HealthPageTests() + { + _comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + + var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); + _comms.SetNotificationOutbox(outbox); + Services.AddSingleton(_comms); + + var aggregator = Substitute.For(); + aggregator.GetAllSiteStates() + .Returns(new Dictionary()); + Services.AddSingleton(aggregator); + + var siteRepo = Substitute.For(); + siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List())); + Services.AddSingleton(siteRepo); + + var claims = new[] + { + new Claim("Username", "tester"), + new Claim(ClaimTypes.Role, "Admin"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + [Fact] + public void Renders_OutboxKpiTiles_WithValues() + { + var cut = Render(); + + // KPI data arrives via an async actor Ask after first render. + cut.WaitForAssertion(() => + { + Assert.Contains("Notification Outbox", cut.Markup); + Assert.Contains("Queue Depth", cut.Markup); + Assert.Contains("Stuck", cut.Markup); + Assert.Contains("Parked", cut.Markup); + // KPI numeric values surface in the tiles. + Assert.Contains(">12<", cut.Markup); // QueueDepth + Assert.Contains(">4<", cut.Markup); // StuckCount + Assert.Contains(">3<", cut.Markup); // ParkedCount + }); + } + + [Fact] + public void OutboxKpiFailure_ShowsGracefulFallback() + { + _kpiReply = new NotificationKpiResponse( + "k", false, "outbox repository unavailable", 0, 0, 0, 0, null); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // Failure must not crash the page; tiles fall back to a dash. + Assert.Contains("Notification Outbox", cut.Markup); + Assert.Contains("Queue Depth", cut.Markup); + Assert.Contains(">—<", 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 the KPI request + /// with the test's currently-scripted response. + /// + private sealed class ScriptedOutboxActor : ReceiveActor + { + public ScriptedOutboxActor(HealthPageTests test) + { + Receive(_ => Sender.Tell(test._kpiReply)); + } + } +}