From 22bac058ddf31df765a89fbcaf9c5a1962dae040 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 06:09:43 -0400 Subject: [PATCH] feat(central-ui): Notification KPIs page with per-site breakdown --- .../Notifications/NotificationKpis.razor | 209 ++++++++++++++++++ .../Pages/NotificationKpisPageTests.cs | 148 +++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor new file mode 100644 index 0000000..70e3c06 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor @@ -0,0 +1,209 @@ +@page "/notifications/kpis" +@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Messages.Notification +@using ScadaLink.Commons.Types.Notifications +@using ScadaLink.Communication +@inject CommunicationService CommunicationService +@inject ISiteRepository SiteRepository +@inject ILogger Logger + +
+
+

Notification KPIs

+ +
+ + @* ── Global 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 +
+
+
+
+ } + + @* ── Per-site breakdown ── *@ +
Per-site breakdown
+ @if (_perSiteError != null) + { +
Per-site KPIs unavailable: @_perSiteError
+ } + else if (_perSite.Count == 0) + { +
+
+
No per-site activity.
+
+
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var s in _perSite) + { + + + + + + + + + } + +
SiteQueue DepthStuckParkedDelivered (last interval)Oldest Pending Age
@SiteName(s.SourceSiteId)@s.QueueDepth@s.StuckCount@s.ParkedCount@s.DeliveredLastInterval@FormatAge(s.OldestPendingAge)
+
+ } +
+ +@code { + private List _sites = new(); + + private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null); + private string? _kpiError; + + private IReadOnlyList _perSite = Array.Empty(); + private string? _perSiteError; + + private bool _loading; + + protected override async Task OnInitializedAsync() + { + try + { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + } + catch (Exception ex) + { + // Non-fatal — the per-site table falls back to raw site identifiers. + Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown."); + } + + await RefreshAll(); + } + + private async Task RefreshAll() + { + _loading = true; + // Race-free despite both tasks mutating component fields: Blazor Server runs + // every continuation on the circuit's single-threaded synchronization context. + await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis()); + _loading = false; + } + + private async Task LoadGlobalKpis() + { + 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 LoadPerSiteKpis() + { + try + { + var response = await CommunicationService.GetPerSiteNotificationKpisAsync( + new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N"))); + if (response.Success) + { + _perSite = response.Sites; + _perSiteError = null; + } + else + { + _perSiteError = response.ErrorMessage ?? "Per-site KPI query failed."; + } + } + catch (Exception ex) + { + _perSiteError = $"Per-site KPI query failed: {ex.Message}"; + } + } + + private string SiteName(string siteId) => + _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId; + + 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"; + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs new file mode 100644 index 0000000..c19aa5e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -0,0 +1,148 @@ +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 NSubstitute; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Notification; +using ScadaLink.Commons.Types.Notifications; +using ScadaLink.Communication; +using ScadaLink.Security; +using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// bUnit rendering tests for the Notification KPIs page. +/// +/// Testability note: is a concrete class with +/// non-virtual methods, so NSubstitute cannot intercept it. Both the global and +/// per-site KPI calls route through an injected (the +/// notification-outbox proxy), so the tests wire a real, lightweight +/// with a scripted that +/// answers both and +/// — the same seam +/// SetNotificationOutbox exists for. +/// +public class NotificationKpisPageTests : BunitContext +{ + private readonly ActorSystem _system = ActorSystem.Create("notif-kpis-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 PerSiteNotificationKpiResponse _perSiteReply = + new("p", true, null, new List + { + new("plant-a", QueueDepth: 4, StuckCount: 1, ParkedCount: 0, + DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)), + }); + + public NotificationKpisPageTests() + { + _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 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(NotificationKpisPage) + .GetCustomAttributes(typeof(AuthorizeAttribute), true) + .Cast() + .FirstOrDefault(); + + Assert.NotNull(attr); + Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); + } + + [Fact] + public void RendersGlobalTilesAndPerSiteRows() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("Queue Depth", cut.Markup); + Assert.Contains("7", cut.Markup); // global tile value + // Per-site row — site identifier "plant-a" resolves to its friendly name. + Assert.Contains("Plant A", cut.Markup); + }); + } + + [Fact] + public void ShowsKpiError_WhenGlobalKpiQueryFails() + { + _kpiReply = new NotificationKpiResponse( + "k", false, "kpi down", 0, 0, 0, 0, null); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup)); + } + + [Fact] + public void ShowsPerSiteEmptyState_WhenNoSites() + { + _perSiteReply = new PerSiteNotificationKpiResponse( + "p", true, null, new List()); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("No per-site activity", 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 KPI message + /// type with the test's currently-scripted response. + /// + private sealed class ScriptedOutboxActor : ReceiveActor + { + public ScriptedOutboxActor(NotificationKpisPageTests test) + { + Receive(_ => Sender.Tell(test._kpiReply)); + Receive(_ => Sender.Tell(test._perSiteReply)); + } + } +}