feat(kpi): K16 — Health dashboard per-site trend panel

This commit is contained in:
Joseph Doherty
2026-06-17 20:36:09 -04:00
parent 3595a41349
commit 7d7c6cbb05
2 changed files with 305 additions and 0 deletions
@@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health;
@@ -65,6 +66,17 @@ public class HealthPageTests : BunitContext
.Returns(new Dictionary<string, SiteHealthState>());
Services.AddSingleton(aggregator);
// M6 K16 — the Health page now injects IKpiHistoryQueryService to feed the
// per-site Site Health Trends panel. Stub it with a known non-empty series
// so the page resolves the dependency and the trend charts have data; the
// dedicated trend tests below seed sites / override behaviour.
var kpiHistory = Substitute.For<IKpiHistoryQueryService>();
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>>(SampleSeries()));
Services.AddSingleton(kpiHistory);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
@@ -210,6 +222,89 @@ public class HealthPageTests : BunitContext
});
}
[Fact]
public void Renders_SiteHealthTrends_PanelAndChart_ForSelectedSite()
{
// Seed one site so the trend panel's selector has an option and the
// default-site load produces charts.
SeedSites("site-a");
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// The panel + its site selector render at the documented hooks.
Assert.Contains("data-test=\"site-health-trends\"", cut.Markup);
Assert.Contains("data-test=\"site-health-trends-site\"", cut.Markup);
// The four metric charts render (the shared KpiTrendChart slug hook),
// and the seeded non-empty series draws a polyline.
Assert.Contains("kpi-trend-connections-down", cut.Markup);
Assert.Contains("kpi-trend-dead-letters", cut.Markup);
Assert.Contains("kpi-trend-script-errors", cut.Markup);
Assert.Contains("<polyline", cut.Markup);
});
}
[Fact]
public void SiteHealthTrendsFailure_DoesNotBreakDashboard()
{
SeedSites("site-a");
// The KPI-history service throws on every query — the trend load is
// best-effort, so the dashboard (and its tiles) must still render.
var faulting = Substitute.For<IKpiHistoryQueryService>();
faulting.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 unavailable"));
Services.AddSingleton(faulting);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// No unhandled exception: the core dashboard tiles still render, and
// the panel falls back to the per-chart unavailable placeholder.
Assert.Contains("Notification Outbox", cut.Markup);
Assert.Contains("data-test=\"site-health-trends\"", cut.Markup);
Assert.Contains("Trend data unavailable.", cut.Markup);
});
}
// Re-seeds the aggregator substitute so the trend panel's site selector has
// options. Each site id maps to a minimal online SiteHealthState (a null
// report is fine — the trend panel keys off the site ids, not the report).
private void SeedSites(params string[] siteIds)
{
var aggregator = Substitute.For<ICentralHealthAggregator>();
var states = siteIds.ToDictionary(
id => id,
id => new SiteHealthState
{
SiteId = id,
IsOnline = true,
LastHeartbeatAt = DateTimeOffset.UtcNow,
});
aggregator.GetAllSiteStates()
.Returns(new Dictionary<string, SiteHealthState>(states));
Services.AddSingleton(aggregator);
}
// A known non-empty (≥2-point) series so KpiTrendChart renders a polyline
// rather than the single-sample / unavailable placeholder.
private static IReadOnlyList<KpiSeriesPoint> SampleSeries()
{
var baseUtc = DateTime.UtcNow.AddHours(-24);
return new List<KpiSeriesPoint>
{
new(baseUtc, 1),
new(baseUtc.AddHours(6), 3),
new(baseUtc.AddHours(12), 2),
new(baseUtc.AddHours(18), 5),
};
}
protected override void Dispose(bool disposing)
{
if (disposing)