feat(ui): health dashboard shows metrics-stale badge and offline-since timestamp
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
@inject CommunicationService CommunicationService
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
@inject IKpiHistoryQueryService KpiHistory
|
||||
@inject Microsoft.Extensions.Options.IOptions<HealthMonitoringOptions> HealthOptions
|
||||
|
||||
<div class="container-fluid mt-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>
|
||||
}
|
||||
<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>
|
||||
<small class="text-muted">
|
||||
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 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 Dictionary<string, string> _siteNames = new();
|
||||
private Timer? _refreshTimer;
|
||||
|
||||
@@ -82,6 +82,11 @@ public class HealthPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||
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<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
|
||||
// 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).
|
||||
|
||||
Reference in New Issue
Block a user