fix(health-monitoring): resolve HealthMonitoring-015 — nullable LastReportReceivedAt

A heartbeat-registered site that has never sent a full report now has
LastReportReceivedAt = null instead of the year-0001 sentinel. TimestampDisplay
accepts DateTimeOffset? and renders null as a placeholder ('awaiting first
report') rather than a ~2000-year-stale date. Cross-module: HealthMonitoring +
CentralUI.
This commit is contained in:
Joseph Doherty
2026-05-17 05:43:05 -04:00
parent 7da303d7bb
commit e55bd46ca1
7 changed files with 137 additions and 32 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-17 | | Last reviewed | 2026-05-17 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `39d737e` | | Commit reviewed | `39d737e` |
| Open findings | 1 | | Open findings | 0 |
## Summary ## Summary
@@ -44,8 +44,9 @@ crash-class**. They are residual polish items rather than behaviour regressions:
an inaccurate offline-check-interval comment (HealthMonitoring-013), unvalidated an inaccurate offline-check-interval comment (HealthMonitoring-013), unvalidated
`HealthMonitoringOptions` intervals that crash the hosted service on `HealthMonitoringOptions` intervals that crash the hosted service on
misconfiguration (HealthMonitoring-014), a heartbeat-only registered site left misconfiguration (HealthMonitoring-014), a heartbeat-only registered site left
with a year-0001 `LastReportReceivedAt` that the UI's staleness display must with a year-0001 `LastReportReceivedAt` that the UI's staleness display would
special-case (HealthMonitoring-015), and `CollectReport` reading otherwise render verbatim (HealthMonitoring-015 — resolved via a coordinated
HealthMonitoring + CentralUI change), and `CollectReport` reading
`DateTimeOffset.UtcNow` directly instead of the module's now-standard injected `DateTimeOffset.UtcNow` directly instead of the module's now-standard injected
`TimeProvider` (HealthMonitoring-016). The module remains small, readable, and `TimeProvider` (HealthMonitoring-016). The module remains small, readable, and
broadly faithful to the design intent. broadly faithful to the design intent.
@@ -684,7 +685,7 @@ intervals and the `CentralOfflineTimeout < OfflineTimeout` case.
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:122-130`, `src/ScadaLink.HealthMonitoring/SiteHealthState.cs:27` | | Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:122-130`, `src/ScadaLink.HealthMonitoring/SiteHealthState.cs:27` |
**Description** **Description**
@@ -712,25 +713,25 @@ nullable option is safer and matches the existing `LatestReport` treatment.
**Resolution** **Resolution**
_Unresolved — left Open; requires a coordinated cross-module change._ Root cause Resolved 2026-05-17. Root cause confirmed against source — `CentralHealthAggregator.MarkHeartbeat`
confirmed against source: `CentralHealthAggregator.MarkHeartbeat` registers an registered a heartbeat-only site with `LastReportReceivedAt = default` (`0001-01-01`),
unknown site with `LastReportReceivedAt = default` (`0001-01-01`), and the audit of which `Health.razor:74` passed verbatim into `TimestampDisplay`, rendering a
the single UI reader (`src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor:74`) ~2000-year-stale timestamp. Applied the finding's recommended cross-module fix.
confirms the bug is live — it passes `state.LastReportReceivedAt` straight into the **HealthMonitoring:** `SiteHealthState.LastReportReceivedAt` is now `DateTimeOffset?`,
`TimestampDisplay` component, which would render the year-0001 sentinel verbatim for and `MarkHeartbeat`'s unknown-site registration sets it to `null` — "no report yet"
a heartbeat-only site. The finding's recommended fix — change the field to is now an explicit nullable state, consistent with the already-nullable `LatestReport`.
`DateTimeOffset?` — is the correct one, but it is a **breaking public-API change**: `ProcessReport` continues to set a real instant. **CentralUI:** `TimestampDisplay.Value`
`TimestampDisplay.Value` is a non-nullable `DateTimeOffset` parameter, so making now accepts `DateTimeOffset?` and renders `null` as a plain `text-muted` placeholder
`LastReportReceivedAt` nullable stops `Health.razor` compiling, and the (default "never", configurable via a new `NullText` parameter); existing non-null
`CentralUI` module is explicitly outside this task's edit scope. The only fix that callers (`AuditLog`, `EventLogs`, `Deployments`) are unaffected by the implicit
stays module-local would be to stamp the heartbeat time into `LastReportReceivedAt`, widening. `Health.razor`'s "Last report" cell passes `NullText="awaiting first report"`
which is semantically dishonest (the field documents "time the latest full report so a heartbeat-only site reads cleanly. Regression tests:
was processed") and would simply move the bug rather than fix it. The correct `CentralHealthAggregatorTests.MarkHeartbeat_RegistersUnknownSite_WithNullLastReportReceivedAt`
resolution therefore needs the HealthMonitoring change (`DateTimeOffset?`) **and** a and `ProcessReport_SetsLastReportReceivedAt_ForHeartbeatRegisteredSite` (HealthMonitoring —
matching `CentralUI` change (a null-checked `TimestampDisplay` or an "awaiting first the first would not compile against the pre-fix non-nullable field), and
report" placeholder) applied together — a design decision spanning two modules. `TimestampDisplayTests.Render_NullValue_ShowsNeverPlaceholder`,
**Called out for follow-up: open as a coordinated HealthMonitoring + CentralUI work `Render_NullValue_DoesNotRenderYear0001Sentinel`, `Render_NonNullValue_ShowsFormattedTimestamp`
item.** (CentralUI).
### HealthMonitoring-016 — `SiteHealthCollector.CollectReport` reads `DateTimeOffset.UtcNow` directly instead of an injected `TimeProvider` ### HealthMonitoring-016 — `SiteHealthCollector.CollectReport` reads `DateTimeOffset.UtcNow` directly instead of an injected `TimeProvider`

View File

@@ -71,7 +71,7 @@
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong> <strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
</div> </div>
<small class="text-muted"> <small class="text-muted">
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" /> Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" /> | Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
| Seq: @state.LastSequenceNumber | Seq: @state.LastSequenceNumber
</small> </small>

View File

@@ -1,8 +1,20 @@
@* Displays a UTC DateTimeOffset formatted for display. Tooltip shows UTC value. *@ @* Displays a UTC DateTimeOffset formatted for display. Tooltip shows UTC value.
A null Value renders as a plain "never" placeholder — used for timestamps that
have not happened yet (e.g. a heartbeat-only site with no full report). *@
<span title="@Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") UTC">@Value.LocalDateTime.ToString(Format)</span> @if (Value is { } value)
{
<span title="@value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") UTC">@value.LocalDateTime.ToString(Format)</span>
}
else
{
<span class="text-muted">@NullText</span>
}
@code { @code {
[Parameter, EditorRequired] public DateTimeOffset Value { get; set; } [Parameter, EditorRequired] public DateTimeOffset? Value { get; set; }
[Parameter] public string Format { get; set; } = "yyyy-MM-dd HH:mm:ss"; [Parameter] public string Format { get; set; } = "yyyy-MM-dd HH:mm:ss";
/// <summary>Text shown when <see cref="Value"/> is null.</summary>
[Parameter] public string NullText { get; set; } = "never";
} }

View File

@@ -118,12 +118,14 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
if (!_siteStates.TryGetValue(siteId, out var existing)) if (!_siteStates.TryGetValue(siteId, out var existing))
{ {
// Unknown site — register it as online, awaiting its first // Unknown site — register it as online, awaiting its first
// full report. LatestReport stays null until ProcessReport runs. // full report. LatestReport and LastReportReceivedAt both stay
// null until ProcessReport runs — "no report yet" is an explicit
// nullable state, not a year-0001 sentinel the UI must special-case.
var registered = new SiteHealthState var registered = new SiteHealthState
{ {
SiteId = siteId, SiteId = siteId,
LatestReport = null, LatestReport = null,
LastReportReceivedAt = default, LastReportReceivedAt = null,
LastHeartbeatAt = receivedAt, LastHeartbeatAt = receivedAt,
LastSequenceNumber = 0, LastSequenceNumber = 0,
IsOnline = true IsOnline = true

View File

@@ -21,10 +21,13 @@ public sealed record SiteHealthState
public SiteHealthReport? LatestReport { get; init; } public SiteHealthReport? LatestReport { get; init; }
/// <summary> /// <summary>
/// Time the latest full <see cref="SiteHealthReport"/> was processed. /// Time the latest full <see cref="SiteHealthReport"/> was processed, or
/// Used by the UI to surface report staleness during failover. /// <c>null</c> if the site is known only via heartbeats and has not yet sent
/// a report. Used by the UI to surface report staleness during failover;
/// the <c>null</c> case must be rendered as "no report yet" rather than as a
/// timestamp (a <c>default</c> sentinel would display as year-0001).
/// </summary> /// </summary>
public DateTimeOffset LastReportReceivedAt { get; init; } public DateTimeOffset? LastReportReceivedAt { get; init; }
/// <summary> /// <summary>
/// Time the most recent signal of any kind (full report OR heartbeat) was /// Time the most recent signal of any kind (full report OR heartbeat) was

View File

@@ -0,0 +1,50 @@
using Bunit;
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for HealthMonitoring-015. A heartbeat-only registered site has
/// a <c>null</c> <c>LastReportReceivedAt</c> ("no full report yet"). The health
/// dashboard passes that value straight into <see cref="TimestampDisplay"/>, so the
/// component's <c>Value</c> must accept <c>DateTimeOffset?</c> and render a
/// <c>null</c> as a human-readable placeholder ("never") instead of the
/// <c>DateTimeOffset.MinValue</c> year-0001 sentinel. Non-null callers must keep
/// rendering the formatted timestamp exactly as before.
/// </summary>
public class TimestampDisplayTests : BunitContext
{
[Fact]
public void Render_NonNullValue_ShowsFormattedTimestamp()
{
var value = new DateTimeOffset(2026, 5, 17, 14, 30, 45, TimeSpan.Zero);
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)value)
.Add(p => p.Format, "HH:mm:ss"));
var span = cut.Find("span");
Assert.Equal(value.LocalDateTime.ToString("HH:mm:ss"), span.TextContent.Trim());
Assert.Contains("2026-05-17 14:30:45 UTC", span.GetAttribute("title")!);
}
[Fact]
public void Render_NullValue_ShowsNeverPlaceholder()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null)
.Add(p => p.Format, "HH:mm:ss"));
Assert.Contains("never", cut.Markup, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Render_NullValue_DoesNotRenderYear0001Sentinel()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null));
// The year-0001 DateTimeOffset.MinValue sentinel must never reach the UI.
Assert.DoesNotContain("0001", cut.Markup);
}
}

View File

@@ -240,6 +240,43 @@ public class CentralHealthAggregatorTests
Assert.Equal(now, state.LastHeartbeatAt); Assert.Equal(now, state.LastHeartbeatAt);
} }
/// <summary>
/// Regression test for HealthMonitoring-015. A heartbeat-only registered site
/// has never processed a full report, so <see cref="SiteHealthState.LastReportReceivedAt"/>
/// must be <c>null</c> — not the <c>DateTimeOffset.MinValue</c> (year-0001)
/// sentinel that the UI would otherwise render as a ~2000-year-stale timestamp.
/// The "no report yet" signal must be an explicit nullable state, consistent
/// with <see cref="SiteHealthState.LatestReport"/>.
/// </summary>
[Fact]
public void MarkHeartbeat_RegistersUnknownSite_WithNullLastReportReceivedAt()
{
_aggregator.MarkHeartbeat("site-new", _timeProvider.GetUtcNow());
var state = _aggregator.GetSiteState("site-new");
Assert.NotNull(state);
Assert.Null(state.LastReportReceivedAt);
}
/// <summary>
/// Regression test for HealthMonitoring-015. Once a full report is processed
/// for a heartbeat-registered site, <see cref="SiteHealthState.LastReportReceivedAt"/>
/// becomes a real (non-null) instant.
/// </summary>
[Fact]
public void ProcessReport_SetsLastReportReceivedAt_ForHeartbeatRegisteredSite()
{
_aggregator.MarkHeartbeat("site-new", _timeProvider.GetUtcNow());
_timeProvider.Advance(TimeSpan.FromSeconds(5));
var reportTime = _timeProvider.GetUtcNow();
_aggregator.ProcessReport(MakeReport("site-new", 1));
var state = _aggregator.GetSiteState("site-new");
Assert.NotNull(state);
Assert.Equal(reportTime, state.LastReportReceivedAt);
}
[Fact] [Fact]
public void MarkHeartbeat_KeepsSiteOnline_BetweenReports() public void MarkHeartbeat_KeepsSiteOnline_BetweenReports()
{ {