feat(kpi): K14 — Site Calls trend charts
This commit is contained in:
@@ -5,9 +5,12 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
|
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
|
||||||
@using ZB.MOM.WW.ScadaBridge.Communication
|
@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 CommunicationService CommunicationService
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
|
@inject IKpiHistoryQueryService KpiHistory
|
||||||
@inject ILogger<SiteCallsReport> Logger
|
@inject ILogger<SiteCallsReport> Logger
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
@@ -230,6 +233,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* ── 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. *@
|
||||||
|
<div class="mt-4" data-test="site-calls-trends">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0"
|
||||||
|
@onclick="ToggleTrendsAsync"
|
||||||
|
aria-expanded="@(_trendsExpanded ? "true" : "false")"
|
||||||
|
data-test="site-calls-trends-toggle">
|
||||||
|
<span class="me-1">@(_trendsExpanded ? "▾" : "▸")</span>
|
||||||
|
<span class="h5 mb-0">Trends</span>
|
||||||
|
</button>
|
||||||
|
@if (_trendsExpanded)
|
||||||
|
{
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
|
||||||
|
<button type="button"
|
||||||
|
class="btn @(_windowHours == 24 ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
@onclick="() => SetWindowAsync(24)" disabled="@_trendsLoading">24h</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn @(_windowHours == 168 ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
@onclick="() => SetWindowAsync(168)" disabled="@_trendsLoading">7d</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_trendsExpanded)
|
||||||
|
{
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<KpiTrendChart Title="Buffered"
|
||||||
|
Points="@_bufferedSeries"
|
||||||
|
IsAvailable="@_bufferedAvailable"
|
||||||
|
ErrorMessage="@_bufferedError" />
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<KpiTrendChart Title="Parked"
|
||||||
|
Points="@_parkedSeries"
|
||||||
|
IsAvailable="@_parkedAvailable"
|
||||||
|
ErrorMessage="@_parkedError" />
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<KpiTrendChart Title="Failed / interval"
|
||||||
|
Points="@_failedSeries"
|
||||||
|
IsAvailable="@_failedAvailable"
|
||||||
|
ErrorMessage="@_failedError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── Row detail modal ── *@
|
@* ── Row detail modal ── *@
|
||||||
|
|||||||
+100
@@ -3,8 +3,10 @@ using Microsoft.AspNetCore.WebUtilities;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
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.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
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;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls;
|
||||||
|
|
||||||
@@ -94,6 +96,29 @@ public partial class SiteCallsReport
|
|||||||
private DateTime? _fromFilter;
|
private DateTime? _fromFilter;
|
||||||
private DateTime? _toFilter;
|
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<KpiSeriesPoint>? _bufferedSeries;
|
||||||
|
private bool _bufferedAvailable = true;
|
||||||
|
private string? _bufferedError;
|
||||||
|
|
||||||
|
private IReadOnlyList<KpiSeriesPoint>? _parkedSeries;
|
||||||
|
private bool _parkedAvailable = true;
|
||||||
|
private string? _parkedError;
|
||||||
|
|
||||||
|
private IReadOnlyList<KpiSeriesPoint>? _failedSeries;
|
||||||
|
private bool _failedAvailable = true;
|
||||||
|
private string? _failedError;
|
||||||
|
|
||||||
private bool HasNextPage => _nextCursor is not null;
|
private bool HasNextPage => _nextCursor is not null;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -521,4 +546,79 @@ public partial class SiteCallsReport
|
|||||||
|
|
||||||
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
|
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Trends (K14) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ToggleTrendsAsync()
|
||||||
|
{
|
||||||
|
_trendsExpanded = !_trendsExpanded;
|
||||||
|
if (_trendsExpanded && _bufferedSeries is null && _parkedSeries is null && _failedSeries is null)
|
||||||
|
{
|
||||||
|
await LoadTrendsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Switch the trend window (24h / 7d) and re-load the series.</summary>
|
||||||
|
private async Task SetWindowAsync(int windowHours)
|
||||||
|
{
|
||||||
|
if (_windowHours == windowHours)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowHours = windowHours;
|
||||||
|
await LoadTrendsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load all three SiteCallAudit / Global trend series for the current window.
|
||||||
|
/// Best-effort: each <see cref="IKpiHistoryQueryService.GetSeriesAsync"/> is
|
||||||
|
/// wrapped independently so one metric's failure renders only that chart
|
||||||
|
/// unavailable, and a throw never propagates to break the Site Calls grid.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(IReadOnlyList<KpiSeriesPoint>? 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
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.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
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.Communication;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
|
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 ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
|
||||||
private readonly CommunicationService _comms;
|
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<IKpiHistoryQueryService>();
|
||||||
|
|
||||||
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
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(_comms);
|
||||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
Services.AddSingleton<IDialogService>(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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(new List<KpiSeriesPoint>
|
||||||
|
{
|
||||||
|
new(DateTime.UtcNow.AddHours(-2), 3),
|
||||||
|
new(DateTime.UtcNow.AddHours(-1), 7),
|
||||||
|
new(DateTime.UtcNow, 5),
|
||||||
|
}));
|
||||||
|
Services.AddSingleton(_kpiHistory);
|
||||||
|
|
||||||
var siteRepo = Substitute.For<ISiteRepository>();
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||||
@@ -510,6 +531,91 @@ public class SiteCallsReportPageTests : BunitContext
|
|||||||
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
|
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<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
// 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<SiteCallsReportPage>();
|
||||||
|
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("<polyline", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrendsSection_WindowToggle_ReloadsSeries()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ =>
|
||||||
|
throw new InvalidOperationException("KPI backend down"));
|
||||||
|
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
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("<polyline", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
|
|||||||
Reference in New Issue
Block a user