diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor index 306d67ef..65fd8d93 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor @@ -4,9 +4,12 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification @using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi @using ZB.MOM.WW.ScadaBridge.Communication +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @inject CommunicationService CommunicationService @inject ISiteRepository SiteRepository +@inject IKpiHistoryQueryService KpiHistory @inject ILogger Logger
@@ -158,6 +161,39 @@
} + + @* ── Trends (T11: first KPI-history consumer) ── *@ +
+
Trends
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
@code { @@ -175,6 +211,16 @@ private bool _loading; + // ── Trends (T11: first KPI-history consumer) ── + // Window in hours: 24h (default) or 168h (7d). Toggling re-queries. + private int _windowHours = 24; + private bool _trendsLoading; + private bool _trendsAvailable = true; + private string? _trendsError; + private IReadOnlyList? _queueDepthSeries; + private IReadOnlyList? _parkedSeries; + private IReadOnlyList? _deliveredSeries; + protected override async Task OnInitializedAsync() { try @@ -195,10 +241,55 @@ _loading = true; // Race-free despite all tasks mutating component fields: Blazor Server runs // every continuation on the circuit's single-threaded synchronization context. - await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis()); + await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis(), LoadTrends()); _loading = false; } + private async Task SetWindowAsync(int windowHours) + { + if (_windowHours == windowHours) + { + return; + } + + _windowHours = windowHours; + await LoadTrends(); + } + + private async Task LoadTrends() + { + _trendsLoading = true; + try + { + var toUtc = DateTime.UtcNow; + var fromUtc = toUtc - TimeSpan.FromHours(_windowHours); + + // Best-effort: one query failure must NOT break the page — on any + // exception the charts fall back to the unavailable placeholder while + // the KPI tiles above stay rendered. + _queueDepthSeries = await GetSeries("queueDepth", fromUtc, toUtc); + _parkedSeries = await GetSeries("parkedCount", fromUtc, toUtc); + _deliveredSeries = await GetSeries("deliveredLastInterval", fromUtc, toUtc); + + _trendsAvailable = true; + _trendsError = null; + } + catch (Exception ex) + { + _trendsAvailable = false; + _trendsError = "Trend data unavailable."; + Logger.LogWarning(ex, "Failed to load notification-outbox KPI trend series."); + } + finally + { + _trendsLoading = false; + } + } + + private Task> GetSeries(string metric, DateTime fromUtc, DateTime toUtc) => + KpiHistory.GetSeriesAsync( + KpiSources.NotificationOutbox, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc); + private async Task LoadGlobalKpis() { try diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs index ce7e3a40..f08eb0e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -10,8 +10,10 @@ using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Security; using NotificationKpisPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationKpis; @@ -46,6 +48,18 @@ public class NotificationKpisPageTests : BunitContext DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)), }); + // KPI-history trend service (K13 / T11). Default substitute returns a + // 3-point series for any (source, metric, scope, …) tuple so every chart + // renders a polyline; individual tests can re-stub it (e.g. to throw). + private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For(); + + private static IReadOnlyList SampleSeries() => new List + { + new(DateTime.UtcNow.AddHours(-3), 3), + new(DateTime.UtcNow.AddHours(-2), 5), + new(DateTime.UtcNow.AddHours(-1), 4), + }; + public NotificationKpisPageTests() { _comms = new CommunicationService( @@ -66,6 +80,12 @@ public class NotificationKpisPageTests : BunitContext })); Services.AddSingleton(siteRepo); + _kpiHistory.GetSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult(SampleSeries())); + Services.AddSingleton(_kpiHistory); + var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), @@ -142,6 +162,47 @@ public class NotificationKpisPageTests : BunitContext cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup)); } + [Fact] + public void RendersTrendCharts_WhenSeriesAvailable() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + // The Trends section container is present … + Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]")); + // … and at least one trend chart renders an actual polyline (a chart + // needs IsAvailable + >= 2 points, which the substitute supplies). + Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]")); + Assert.Contains("(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => throw new InvalidOperationException("kpi history down")); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // Tiles still render. + Assert.Contains("Queue Depth", cut.Markup); + Assert.Contains("7", cut.Markup); + // Trends section + chart placeholders are present; no polyline drawn. + Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]")); + Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]")); + Assert.DoesNotContain("