From 87c7255912b8c100589ffc916bee0e90b2adae94 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 8 Jul 2026 16:23:43 -0400 Subject: [PATCH] feat(ui): health dashboard shows metrics-stale badge and offline-since timestamp --- .../Components/Pages/Monitoring/Health.razor | 22 +++++++ .../Pages/HealthPageTests.cs | 61 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor index 2cc8fc12..58b53d52 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor @@ -18,6 +18,7 @@ @inject CommunicationService CommunicationService @inject IAuditLogQueryService AuditLogQueryService @inject IKpiHistoryQueryService KpiHistory +@inject Microsoft.Extensions.Options.IOptions HealthOptions
@@ -199,6 +200,17 @@ @OfflineGlyph Offline } @siteName@(isCentral ? "" : $" ({siteId})") + @if (state.IsOnline && state.IsMetricsStale) + { + + Metrics stale + + } + @if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt) + { + offline since @changedAt.ToString("u") + }
Last report: @@ -419,6 +431,16 @@ private const string PrimaryGlyph = "▲"; // ▲ private const string StandbyGlyph = "△"; // △ + // Human-friendly rendering of the configured metrics-stale window for the + // "Metrics stale" badge tooltip (e.g. "2 minutes"). + private string StaleTimeoutDisplay => + FormatDuration(HealthOptions.Value.MetricsStaleTimeout); + + private static string FormatDuration(TimeSpan span) => + span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes)) + ? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}" + : $"{span.TotalSeconds:0} seconds"; + private IReadOnlyDictionary _siteStates = new Dictionary(); private Dictionary _siteNames = new(); private Timer? _refreshTimer; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index b07b1de3..e7a4663c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -82,6 +82,11 @@ public class HealthPageTests : BunitContext .Returns(Task.FromResult>(new List())); Services.AddSingleton(siteRepo); + // PLAN-01 Task 14 — the Health page now shows the metrics-stale badge with + // a tooltip naming the configured MetricsStaleTimeout, so it injects the + // health-monitoring options. + Services.AddSingleton(Options.Create(new HealthMonitoringOptions())); + // Audit Log (#23) M7 Bundle E — the Health page now also fetches the // Audit KPI snapshot. Stub it with an empty point-in-time reading so // the existing assertions (Notification Outbox tiles, Online/Offline @@ -275,6 +280,62 @@ public class HealthPageTests : BunitContext }); } + [Fact] + public void Renders_MetricsStaleBadge_ForOnlineButStaleSite() + { + // PLAN-01 Task 14: a site that is online (heartbeating) but whose report + // pipeline died shows a distinct "Metrics stale" warning badge. + SeedStates(new SiteHealthState + { + SiteId = "site-a", + IsOnline = true, + IsMetricsStale = true, + LastHeartbeatAt = DateTimeOffset.UtcNow, + LastReportReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + }); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("Metrics stale", cut.Markup); + // The badge uses the page's Bootstrap warning style. + Assert.Contains("bg-warning", cut.Markup); + }); + } + + [Fact] + public void Renders_OfflineSince_ForOfflineSiteWithStatusChange() + { + var changedAt = new DateTimeOffset(2026, 7, 8, 12, 34, 56, TimeSpan.Zero); + SeedStates(new SiteHealthState + { + SiteId = "site-a", + IsOnline = false, + LastHeartbeatAt = changedAt, + LastStatusChangeAt = changedAt, + }); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("offline since", cut.Markup); + // The "u" (universal sortable) format the page renders the flip with. + Assert.Contains(changedAt.ToString("u"), cut.Markup); + }); + } + + // Re-seeds the aggregator substitute with fully-specified states (as opposed + // to SeedSites' minimal online placeholders). + private void SeedStates(params SiteHealthState[] states) + { + var aggregator = Substitute.For(); + aggregator.GetAllSiteStates() + .Returns(states.ToDictionary(s => s.SiteId)); + Services.AddSingleton(aggregator); + } + // 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).