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 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).
|
||||||
|
|||||||
Reference in New Issue
Block a user