feat(health): report permanently rejected site audit rows on the site health report (ScadaBridge#69)

SiteAuditBacklogSnapshot gains an optional trailing RejectedCount (gauge of
Rejected forward-state rows, read from the site store), carried on the wire as
SiteAuditBacklogSnapshotDto.rejected_count = 4 (additive; old sites read as 0,
old central ignores it). Health page shows an 'Audit rows rejected' badge.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 12:46:34 -05:00
parent 3ae3f1bcef
commit 0eac956d25
10 changed files with 263 additions and 100 deletions
@@ -133,6 +133,29 @@ public class SqliteAuditWriterForwardStateTests : IDisposable
Assert.Equal(1, writer.RejectedPushRowCount);
}
/// <summary>
/// The backlog snapshot reports permanently refused rows via <c>RejectedCount</c> (Gitea #69):
/// a Rejected row is still owed (counted in <c>PendingCount</c>) but is also counted separately,
/// and rows still Pending do not inflate it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BacklogStats_ReportsRejectedCount_SeparatelyFromPending()
{
await using var writer = CreateWriter();
var t = DateTime.UtcNow.AddMinutes(-5);
var poison = await WriteAsync(writer, t);
await WriteAsync(writer, t.AddSeconds(1));
Assert.Equal(0, (await writer.GetBacklogStatsAsync()).RejectedCount);
await writer.RecordRejectedPushAttemptsAsync(new[] { poison }, maxAttempts: 1);
var stats = await writer.GetBacklogStatsAsync();
Assert.Equal(1, stats.RejectedCount);
Assert.Equal(2, stats.PendingCount); // Rejected is still owed to central via the pull
}
/// <summary>
/// The served high-water mark survives a writer restart: rows served before the restart are
/// retired by the first cursor that covers them after it, instead of being stranded owed
@@ -621,6 +621,34 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies the audit-rejected badge renders only when the site reports rejected audit rows (Gitea #69).</summary>
/// <param name="rejected">The reported rejected-row count, or null for no backlog snapshot.</param>
/// <param name="expectBadge">Whether the badge should render.</param>
[Theory]
[InlineData(null, false)] // no backlog snapshot (central self-report, older site)
[InlineData(0, false)]
[InlineData(4, true)]
public void AuditRejectedBadge_RendersOnlyWhenRowsRejected(int? rejected, bool expectBadge)
{
SeedStates(new SiteHealthState
{
SiteId = "site-a",
IsOnline = true,
LastHeartbeatAt = DateTimeOffset.UtcNow,
LatestReport = ReportWithReconciler("site-a", active: true) with
{
SiteAuditBacklog = rejected is { } r
? new SiteAuditBacklogSnapshot(r, null, 0, RejectedCount: r)
: null,
},
});
var cut = RenderHealthPage();
cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
Assert.Equal(expectBadge, cut.Markup.Contains("data-test=\"audit-rejected-badge\""));
}
/// <summary>Verifies that no secret mirror warning renders when no node is failing.</summary>
/// <param name="reported">Whether the site reports secret mirror status at all.</param>
[Theory]
@@ -466,6 +466,38 @@ public class CentralControlDtoMapperTests
Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds);
}
/// <summary>Verifies that the backlog's rejected-row count survives the wire round trip (Gitea #69).</summary>
[Fact]
public void SiteHealthReport_RoundTrip_BacklogRejectedCount_Survives()
{
var original = FullyPopulatedHealthReport() with
{
SiteAuditBacklog = new SiteAuditBacklogSnapshot(5, null, 4096, RejectedCount: 3),
};
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
Assert.Equal(3, roundTripped.SiteAuditBacklog!.RejectedCount);
}
/// <summary>
/// Backward compatibility: a backlog DTO from a pre-#69 site (no rejected_count field on the wire)
/// decodes to RejectedCount 0.
/// </summary>
[Fact]
public void SiteHealthReport_FromDto_BacklogWithoutRejectedCount_DefaultsToZero()
{
var dto = CentralControlDtoMapper.ToDto(FullyPopulatedHealthReport() with
{
SiteAuditBacklog = new SiteAuditBacklogSnapshot(5, null, 4096),
});
dto.SiteAuditBacklog.RejectedCount = 0; // proto3 default == field absent on the wire
var roundTripped = CentralControlDtoMapper.FromDto(Wire(dto));
Assert.Equal(0, roundTripped.SiteAuditBacklog!.RejectedCount);
}
/// <summary>Verifies that a backlog report with an empty queue keeps a null oldest-pending value.</summary>
[Fact]
public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending()