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