feat(ui): health dashboard shows metrics-stale badge and offline-since timestamp

This commit is contained in:
Joseph Doherty
2026-07-08 16:23:43 -04:00
parent c73b7faa11
commit 87c7255912
2 changed files with 83 additions and 0 deletions
@@ -18,6 +18,7 @@
@inject CommunicationService CommunicationService @inject CommunicationService CommunicationService
@inject IAuditLogQueryService AuditLogQueryService @inject IAuditLogQueryService AuditLogQueryService
@inject IKpiHistoryQueryService KpiHistory @inject IKpiHistoryQueryService KpiHistory
@inject Microsoft.Extensions.Options.IOptions<HealthMonitoringOptions> HealthOptions
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -199,6 +200,17 @@
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span> <span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
} }
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong> <strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
@if (state.IsOnline && state.IsMetricsStale)
{
<span class="badge bg-warning text-dark ms-2"
title="Site is heartbeating but has sent no health report for over @StaleTimeoutDisplay — the metrics pipeline may be dead.">
Metrics stale
</span>
}
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
{
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
}
</div> </div>
<small class="text-muted"> <small class="text-muted">
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" /> Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
@@ -419,6 +431,16 @@
private const string PrimaryGlyph = "▲"; // ▲ private const string PrimaryGlyph = "▲"; // ▲
private const string StandbyGlyph = "△"; // △ 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<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>(); private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new(); private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer; private Timer? _refreshTimer;
@@ -82,6 +82,11 @@ public class HealthPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>())); .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
Services.AddSingleton(siteRepo); 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 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 // Audit KPI snapshot. Stub it with an empty point-in-time reading so
// the existing assertions (Notification Outbox tiles, Online/Offline // 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<HealthPage>();
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<HealthPage>();
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<ICentralHealthAggregator>();
aggregator.GetAllSiteStates()
.Returns(states.ToDictionary(s => s.SiteId));
Services.AddSingleton(aggregator);
}
// Re-seeds the aggregator substitute so the trend panel's site selector has // 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 // 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). // report is fine — the trend panel keys off the site ids, not the report).