feat(kpi): K13 — Notification Outbox trend charts (T11 first consumer)
This commit is contained in:
+92
-1
@@ -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<NotificationKpis> Logger
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@@ -158,6 +161,39 @@
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Trends (T11: first KPI-history consumer) ── *@
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-2" data-test="notification-trends">
|
||||
<h5 class="mb-0">Trends</h5>
|
||||
<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>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<KpiTrendChart Title="Queue Depth"
|
||||
Points="@_queueDepthSeries"
|
||||
IsAvailable="@_trendsAvailable"
|
||||
ErrorMessage="@_trendsError" />
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<KpiTrendChart Title="Parked"
|
||||
Points="@_parkedSeries"
|
||||
IsAvailable="@_trendsAvailable"
|
||||
ErrorMessage="@_trendsError" />
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<KpiTrendChart Title="Delivered / interval"
|
||||
Points="@_deliveredSeries"
|
||||
IsAvailable="@_trendsAvailable"
|
||||
ErrorMessage="@_trendsError" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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<KpiSeriesPoint>? _queueDepthSeries;
|
||||
private IReadOnlyList<KpiSeriesPoint>? _parkedSeries;
|
||||
private IReadOnlyList<KpiSeriesPoint>? _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<IReadOnlyList<KpiSeriesPoint>> GetSeries(string metric, DateTime fromUtc, DateTime toUtc) =>
|
||||
KpiHistory.GetSeriesAsync(
|
||||
KpiSources.NotificationOutbox, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
|
||||
|
||||
private async Task LoadGlobalKpis()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -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<IKpiHistoryQueryService>();
|
||||
|
||||
private static IReadOnlyList<KpiSeriesPoint> SampleSeries() => new List<KpiSeriesPoint>
|
||||
{
|
||||
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<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(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<NotificationKpisPage>();
|
||||
|
||||
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("<polyline", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendQueryFailure_DoesNotBreakPage()
|
||||
{
|
||||
// Best-effort contract: a throwing trend query must not surface an
|
||||
// unhandled exception — the KPI tiles still render and the charts fall
|
||||
// back to their unavailable placeholders.
|
||||
_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 history down"));
|
||||
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
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("<polyline", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
|
||||
Reference in New Issue
Block a user