diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor index c23b7a22..046d3d4e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor @@ -5,9 +5,12 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit @using ZB.MOM.WW.ScadaBridge.Communication +@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @inject CommunicationService CommunicationService @inject ISiteRepository SiteRepository @inject IDialogService Dialog +@inject IKpiHistoryQueryService KpiHistory @inject ILogger Logger
@@ -230,6 +233,59 @@
} + + @* ── Trends (K14: collapsible KPI-history charts) ── + A best-effort section — each chart's GetSeriesAsync is wrapped so a KPI + backend hiccup renders the chart unavailable rather than breaking the + grid above. The series load lazily on first expand (and re-load on the + 24h/7d window toggle), so the page's primary job (the call list) is never + blocked on the history query. *@ +
+
+ + @if (_trendsExpanded) + { +
+ + +
+ } +
+ + @if (_trendsExpanded) + { +
+
+ +
+
+ +
+
+ +
+
+ } +
@* ── Row detail modal ── *@ diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs index 98fcaae6..3b1b8056 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -3,8 +3,10 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls; @@ -94,6 +96,29 @@ public partial class SiteCallsReport private DateTime? _fromFilter; private DateTime? _toFilter; + // ── Trends (K14) ────────────────────────────────────────────────────────── + // Collapsible KPI-history charts for the SiteCallAudit / Global series. The + // section is collapsed on init and the series load lazily on first expand + // (and on each 24h/7d window toggle), so the page's primary job — the call + // list — never waits on the history query. Each metric carries its own + // series + availability + error so one metric's failure leaves the other two + // charts intact; a GetSeriesAsync throw never breaks the grid above. + private bool _trendsExpanded; + private bool _trendsLoading; + private int _windowHours = 24; + + private IReadOnlyList? _bufferedSeries; + private bool _bufferedAvailable = true; + private string? _bufferedError; + + private IReadOnlyList? _parkedSeries; + private bool _parkedAvailable = true; + private string? _parkedError; + + private IReadOnlyList? _failedSeries; + private bool _failedAvailable = true; + private string? _failedError; + private bool HasNextPage => _nextCursor is not null; /// @@ -521,4 +546,79 @@ public partial class SiteCallsReport return Task.FromResult(_permittedSiteIds.Contains(resolved.Id)); } + + // ── Trends (K14) ────────────────────────────────────────────────────────── + + /// + /// Expand/collapse the Trends section. The series load on first expand (and + /// stay loaded across collapse/expand cycles); collapsing leaves the loaded + /// series in place so re-expanding is instant. A re-fetch only happens on the + /// window toggle. + /// + private async Task ToggleTrendsAsync() + { + _trendsExpanded = !_trendsExpanded; + if (_trendsExpanded && _bufferedSeries is null && _parkedSeries is null && _failedSeries is null) + { + await LoadTrendsAsync(); + } + } + + /// Switch the trend window (24h / 7d) and re-load the series. + private async Task SetWindowAsync(int windowHours) + { + if (_windowHours == windowHours) + { + return; + } + + _windowHours = windowHours; + await LoadTrendsAsync(); + } + + /// + /// Load all three SiteCallAudit / Global trend series for the current window. + /// Best-effort: each is + /// wrapped independently so one metric's failure renders only that chart + /// unavailable, and a throw never propagates to break the Site Calls grid. + /// + private async Task LoadTrendsAsync() + { + _trendsLoading = true; + + var toUtc = DateTime.UtcNow; + var fromUtc = toUtc - TimeSpan.FromHours(_windowHours); + + (_bufferedSeries, _bufferedAvailable, _bufferedError) = + await LoadSeriesAsync("buffered", fromUtc, toUtc); + (_parkedSeries, _parkedAvailable, _parkedError) = + await LoadSeriesAsync("parked", fromUtc, toUtc); + (_failedSeries, _failedAvailable, _failedError) = + await LoadSeriesAsync("failedLastInterval", fromUtc, toUtc); + + _trendsLoading = false; + } + + /// + /// Fetch one SiteCallAudit / Global series, swallowing any failure into the + /// chart's unavailable state. Returns the points (null on failure), the + /// availability flag, and a short error message for the chart placeholder. + /// + private async Task<(IReadOnlyList? Points, bool Available, string? Error)> + LoadSeriesAsync(string metric, DateTime fromUtc, DateTime toUtc) + { + try + { + var points = await KpiHistory.GetSeriesAsync( + KpiSources.SiteCallAudit, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc); + return (points, true, null); + } + catch (Exception ex) + { + // A KPI-history hiccup must never break the Site Calls page — the + // chart degrades to its unavailable placeholder instead. + Logger.LogWarning(ex, "Failed to load Site Calls trend series for metric {Metric}.", metric); + return (null, false, "Trend data unavailable."); + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index 45315eff..94630c6f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -10,9 +10,11 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Security; using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport; @@ -34,6 +36,11 @@ public class SiteCallsReportPageTests : BunitContext private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests"); private readonly CommunicationService _comms; + // K14: KPI-history facade for the Trends section. Defaults to a known + // non-empty (≥2-point) series so an expanded chart draws a polyline; + // individual tests reconfigure it (e.g. to throw) before rendering. + private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For(); + private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222"); @@ -77,6 +84,20 @@ public class SiteCallsReportPageTests : BunitContext Services.AddSingleton(_comms); Services.AddSingleton(new AlwaysConfirmDialogService()); + // K14: by default every trend series resolves to a known ≥2-point series so + // an expanded chart draws a polyline. Tests that need a failure reconfigure + // this substitute before rendering. + _kpiHistory.GetSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List + { + new(DateTime.UtcNow.AddHours(-2), 3), + new(DateTime.UtcNow.AddHours(-1), 7), + new(DateTime.UtcNow, 5), + })); + Services.AddSingleton(_kpiHistory); + var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(new List @@ -510,6 +531,91 @@ public class SiteCallsReportPageTests : BunitContext Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup); } + // ───────────────────────────────────────────────────────────────────────── + // Trends (K14) — a collapsible section with one KpiTrendChart per metric + // (buffered / parked / failedLastInterval) for SiteCallAudit / Global. The + // section is collapsed on init; expanding it lazy-loads the series. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void TrendsSection_Container_AlwaysRenders() + { + var cut = Render(); + + // The collapsible container (with its toggle) is present even while + // collapsed — only the charts are gated behind the expand. + cut.WaitForAssertion(() => + Assert.NotNull(cut.Find("[data-test='site-calls-trends']"))); + } + + [Fact] + public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries() + { + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + // Collapsed on init — no chart yet. + Assert.Empty(cut.FindAll("[data-test^='kpi-trend-']")); + + // Expand the Trends section; the lazy series load fills the charts. + cut.Find("[data-test='site-calls-trends-toggle']").Click(); + + cut.WaitForAssertion(() => + { + // At least one trend chart rendered, and (because the series has ≥2 + // points) it draws a polyline rather than the unavailable placeholder. + var charts = cut.FindAll("[data-test^='kpi-trend-']"); + Assert.NotEmpty(charts); + Assert.Contains("(); + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + cut.Find("[data-test='site-calls-trends-toggle']").Click(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-test^='kpi-trend-']"))); + + // First expand issued 3 GetSeriesAsync calls (one per metric). + _kpiHistory.ReceivedCalls(); + var afterExpand = _kpiHistory.ReceivedCalls().Count(); + + // Switch the window to 7d — the series re-load (another 3 calls). + cut.FindAll("button").First(b => b.TextContent.Trim() == "7d").Click(); + + cut.WaitForAssertion(() => + Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand)); + } + + [Fact] + public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders() + { + // Best-effort contract: a GetSeriesAsync throw must never break the page. + _kpiHistory.GetSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("KPI backend down")); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + // Expanding triggers the failing loads — the grid and trends container + // both survive; the charts fall back to the unavailable placeholder. + cut.Find("[data-test='site-calls-trends-toggle']").Click(); + + cut.WaitForAssertion(() => + { + Assert.Contains("ERP.GetOrder", cut.Markup); + Assert.NotNull(cut.Find("[data-test='site-calls-trends']")); + // No polyline — the charts degraded to the unavailable em-dash state. + Assert.DoesNotContain("