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