From 0eac956d254992bea7e21929b100c943e8752855 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 12:46:34 -0500 Subject: [PATCH] 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 --- .../Component-HealthMonitoring.md | 2 +- .../Site/SqliteAuditWriter.cs | 8 +- .../Components/Pages/Monitoring/Health.razor | 15 ++ .../Types/SiteAuditBacklogSnapshot.cs | 14 +- .../CentralControlGrpc/CentralControl.cs | 233 +++++++++++------- .../Grpc/CentralControlDtoMapper.cs | 4 +- .../Protos/central_control.proto | 4 + .../SqliteAuditWriterForwardStateTests.cs | 23 ++ .../Pages/HealthPageTests.cs | 28 +++ .../Grpc/CentralControlDtoMapperTests.cs | 32 +++ 10 files changed, 263 insertions(+), 100 deletions(-) diff --git a/docs/requirements/Component-HealthMonitoring.md b/docs/requirements/Component-HealthMonitoring.md index 95961217..30663eda 100644 --- a/docs/requirements/Component-HealthMonitoring.md +++ b/docs/requirements/Component-HealthMonitoring.md @@ -34,7 +34,7 @@ Site clusters (metric collection and reporting). Central cluster (aggregation an | Notification Outbox queue depth | Notification Outbox (central) | Count of `Pending` + `Retrying` notifications — central-computed, not site-reported | | Notification Outbox stuck count | Notification Outbox (central) | Count of `Pending` / `Retrying` notifications older than the configurable stuck-age threshold — central-computed, not site-reported | | Notification Outbox parked count | Notification Outbox (central) | Count of `Parked` notifications — central-computed, not site-reported | -| `SiteAuditBacklog` | Audit Log (site) | Count of `Pending` rows in the site-local `AuditLog` plus oldest-pending-age plus on-disk bytes. Surfaced on the Audit KPI tiles (which border-highlight any non-zero backlog) and sampled into KPI history as `auditBacklogPending`. **No configurable per-site threshold badge exists for this metric** — earlier revisions of this document described one, but none was ever implemented and no such option exists. The only configurable sustained-threshold site badge is the LocalDb replication backlog (register row 33, below). | +| `SiteAuditBacklog` | Audit Log (site) | Count of `Pending` rows in the site-local `AuditLog` plus oldest-pending-age plus on-disk bytes. Surfaced on the Audit KPI tiles (which border-highlight any non-zero backlog) and sampled into KPI history as `auditBacklogPending`. **No configurable per-site threshold badge exists for this metric** — earlier revisions of this document described one, but none was ever implemented and no such option exists. The only configurable sustained-threshold site badge is the LocalDb replication backlog (register row 33, below). Also carries `RejectedCount` (optional, wire field `rejected_count` = 4): rows central permanently refused (`IngestAck.rejected_event_ids`) that the site moved to the `Rejected` forward state — a subset of the pending count; the Health page shows an "Audit rows rejected" badge when non-zero (Gitea #69). | | `SiteAuditWriteFailures` | Audit Log (site) | Count of failed hot-path audit appends at the site since the last health report. | | `AuditRedactionFailure` | Audit Log (central) | Count of payload redactor errors (over-redacted payloads, safety-net hit) since the last interval. | | `LocalDbReplicationConnected` | Consolidated site LocalDb | Whether a peer sync session is currently running on this node. **Nullable — null means "no reading", not "disconnected".** Reported only by a node with replication configured; an unconfigured node reports **null**, never `false` (register row 80 — see *Unconfigured replication reports null* below). | diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs index da18166f..b1728e1e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs @@ -1389,6 +1389,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, ISiteAuditPushLi public Task GetBacklogStatsAsync(CancellationToken ct = default) { int pendingCount; + int rejectedCount; DateTime? oldestPending; // Read via the dedicated _readConnection (under @@ -1409,7 +1410,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, ISiteAuditPushLi // (PushAbandoned / Rejected) still await the reconciliation pull and are never // purged, so they count toward the backlog and its oldest-row age. cmd.CommandText = """ - SELECT COUNT(*), MIN(OccurredAtUtc) + SELECT COUNT(*), MIN(OccurredAtUtc), + COALESCE(SUM(CASE WHEN ForwardState = $rejected THEN 1 ELSE 0 END), 0) FROM audit_forward_state WHERE ForwardState IN ($pending, $pushAbandoned, $rejected); """; @@ -1418,6 +1420,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, ISiteAuditPushLi using var reader = cmd.ExecuteReader(); reader.Read(); pendingCount = reader.GetInt32(0); + rejectedCount = reader.GetInt32(2); oldestPending = reader.IsDBNull(1) ? null : DateTime.Parse(reader.GetString(1), @@ -1453,7 +1456,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, ISiteAuditPushLi return Task.FromResult(new SiteAuditBacklogSnapshot( PendingCount: pendingCount, OldestPendingUtc: oldestPending, - OnDiskBytes: onDiskBytes)); + OnDiskBytes: onDiskBytes, + RejectedCount: rejectedCount)); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor index ec1876eb..572cba24 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor @@ -285,6 +285,14 @@ Secret mirror failing } + @if (HasAuditRejectedRows(state)) + { + + Audit rows rejected + + } @if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt) { offline since @changedAt.ToString("u") @@ -629,6 +637,13 @@ && state.LatestReport?.SecretMirrorStatuses is { } statuses && statuses.Any(n => n.Failures.Count > 0); + // Gitea #69: warn when central has permanently refused site audit rows. Null backlog + // ("no data": central self-report, pre-#69 site) and zero are healthy; offline sites are + // skipped like the other warning badges. + private static bool HasAuditRejectedRows(SiteHealthState state) => + state.IsOnline + && state.LatestReport?.SiteAuditBacklog is { RejectedCount: > 0 }; + // Online central nodes, from the same ClusterNodes list the Nodes column renders. // Fewer than two means no standby, which disables the manual-failover control. This is // a display-side guard only — AkkaManualFailoverService re-checks against live cluster diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/SiteAuditBacklogSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/SiteAuditBacklogSnapshot.cs index 6b805886..24b6048d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/SiteAuditBacklogSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/SiteAuditBacklogSnapshot.cs @@ -26,7 +26,19 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types; /// invariant (alog.md §10) — a steady file-size growth past the retention /// window points at a stuck purge or a stuck forwarder. /// +/// +/// Number of rows currently in the Rejected forward state — rows central permanently +/// refused (listed in the ack's rejected_event_ids) on repeated pushes and that the +/// site moved out of the push queue. They are still owed to central via the reconciliation +/// pull (so they are ALSO included in ), but a non-zero value +/// means central is rejecting rows as un-insertable, which is a data-quality fault worth an +/// operator's attention rather than a drain-lag symptom. A point-in-time gauge read from the +/// site store (survives restarts), unlike the process-local +/// scadabridge.site.audit.rejected_rows counter. Trailing, defaulted member: an older +/// site omits it (reads as 0) and the wire field is optional (Gitea #69). +/// public sealed record SiteAuditBacklogSnapshot( int PendingCount, DateTime? OldestPendingUtc, - long OnDiskBytes); + long OnDiskBytes, + int RejectedCount = 0); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs index 48fd90fe..f0286654 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs @@ -59,102 +59,103 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { "ChVzdWNjZXNzZnVsbHlfcmVzb2x2ZWQYAiABKAUiQwoTVGFnUXVhbGl0eUNv", "dW50c0R0bxIMCgRnb29kGAEgASgFEgsKA2JhZBgCIAEoBRIRCgl1bmNlcnRh", "aW4YAyABKAUiQgoNTm9kZVN0YXR1c0R0bxIQCghob3N0bmFtZRgBIAEoCRIR", - "Cglpc19vbmxpbmUYAiABKAgSDAoEcm9sZRgDIAEoCSKDAQobU2l0ZUF1ZGl0", + "Cglpc19vbmxpbmUYAiABKAgSDAoEcm9sZRgDIAEoCSKbAQobU2l0ZUF1ZGl0", "QmFja2xvZ1NuYXBzaG90RHRvEhUKDXBlbmRpbmdfY291bnQYASABKAUSNgoS", "b2xkZXN0X3BlbmRpbmdfdXRjGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp", - "bWVzdGFtcBIVCg1vbl9kaXNrX2J5dGVzGAMgASgDIqEBChhDb25uZWN0aW9u", - "RW5kcG9pbnRNYXBEdG8SVQoHZW50cmllcxgBIAMoCzJELnNjYWRhYnJpZGdl", - "LmNlbnRyYWxjb250cm9sLnYxLkNvbm5lY3Rpb25FbmRwb2ludE1hcER0by5F", - "bnRyaWVzRW50cnkaLgoMRW50cmllc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2", - "YWx1ZRgCIAEoCToCOAEixQEKEFRhZ1F1YWxpdHlNYXBEdG8STQoHZW50cmll", - "cxgBIAMoCzI8LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlRhZ1F1", - "YWxpdHlNYXBEdG8uRW50cmllc0VudHJ5GmIKDEVudHJpZXNFbnRyeRILCgNr", - "ZXkYASABKAkSQQoFdmFsdWUYAiABKAsyMi5zY2FkYWJyaWRnZS5jZW50cmFs", - "Y29udHJvbC52MS5UYWdRdWFsaXR5Q291bnRzRHRvOgI4ASJQChFOb2RlU3Rh", - "dHVzTGlzdER0bxI7CgVub2RlcxgBIAMoCzIsLnNjYWRhYnJpZGdlLmNlbnRy", - "YWxjb250cm9sLnYxLk5vZGVTdGF0dXNEdG8iPgoWU2VjcmV0TWlycm9yRmFp", - "bHVyZUR0bxITCgtzZWNyZXRfbmFtZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJ", - "IqsBChlTZWNyZXRNaXJyb3JOb2RlU3RhdHVzRHRvEhEKCW5vZGVfbmFtZRgB", - "IAEoCRIyCg5jaGVja2VkX2F0X3V0YxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1", - "Zi5UaW1lc3RhbXASRwoIZmFpbHVyZXMYAyADKAsyNS5zY2FkYWJyaWRnZS5j", - "ZW50cmFsY29udHJvbC52MS5TZWNyZXRNaXJyb3JGYWlsdXJlRHRvImQKGVNl", - "Y3JldE1pcnJvclN0YXR1c0xpc3REdG8SRwoFbm9kZXMYASADKAsyOC5zY2Fk", - "YWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5TZWNyZXRNaXJyb3JOb2RlU3Rh", - "dHVzRHRvIokQChNTaXRlSGVhbHRoUmVwb3J0RHRvEg8KB3NpdGVfaWQYASAB", - "KAkSFwoPc2VxdWVuY2VfbnVtYmVyGAIgASgDEjQKEHJlcG9ydF90aW1lc3Rh", - "bXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEnAKGGRhdGFf", - "Y29ubmVjdGlvbl9zdGF0dXNlcxgEIAMoCzJOLnNjYWRhYnJpZGdlLmNlbnRy", - "YWxjb250cm9sLnYxLlNpdGVIZWFsdGhSZXBvcnREdG8uRGF0YUNvbm5lY3Rp", - "b25TdGF0dXNlc0VudHJ5EmoKFXRhZ19yZXNvbHV0aW9uX2NvdW50cxgFIAMo", - "CzJLLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlNpdGVIZWFsdGhS", - "ZXBvcnREdG8uVGFnUmVzb2x1dGlvbkNvdW50c0VudHJ5EhoKEnNjcmlwdF9l", - "cnJvcl9jb3VudBgGIAEoBRIkChxhbGFybV9ldmFsdWF0aW9uX2Vycm9yX2Nv", - "dW50GAcgASgFEnwKH3N0b3JlX2FuZF9mb3J3YXJkX2J1ZmZlcl9kZXB0aHMY", - "CCADKAsyUy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5TaXRlSGVh", - "bHRoUmVwb3J0RHRvLlN0b3JlQW5kRm9yd2FyZEJ1ZmZlckRlcHRoc0VudHJ5", - "EhkKEWRlYWRfbGV0dGVyX2NvdW50GAkgASgFEh8KF2RlcGxveWVkX2luc3Rh", - "bmNlX2NvdW50GAogASgFEh4KFmVuYWJsZWRfaW5zdGFuY2VfY291bnQYCyAB", - "KAUSHwoXZGlzYWJsZWRfaW5zdGFuY2VfY291bnQYDCABKAUSEQoJbm9kZV9y", - "b2xlGA0gASgJEhUKDW5vZGVfaG9zdG5hbWUYDiABKAkSWgoZZGF0YV9jb25u", - "ZWN0aW9uX2VuZHBvaW50cxgPIAEoCzI3LnNjYWRhYnJpZGdlLmNlbnRyYWxj", - "b250cm9sLnYxLkNvbm5lY3Rpb25FbmRwb2ludE1hcER0bxJUChtkYXRhX2Nv", - "bm5lY3Rpb25fdGFnX3F1YWxpdHkYECABKAsyLy5zY2FkYWJyaWRnZS5jZW50", - "cmFsY29udHJvbC52MS5UYWdRdWFsaXR5TWFwRHRvEhwKFHBhcmtlZF9tZXNz", - "YWdlX2NvdW50GBEgASgFEkcKDWNsdXN0ZXJfbm9kZXMYEiABKAsyMC5zY2Fk", - "YWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5Ob2RlU3RhdHVzTGlzdER0bxIh", - "ChlzaXRlX2F1ZGl0X3dyaXRlX2ZhaWx1cmVzGBMgASgFEh8KF2F1ZGl0X3Jl", - "ZGFjdGlvbl9mYWlsdXJlGBQgASgFElYKEnNpdGVfYXVkaXRfYmFja2xvZxgV", - "IAEoCzI6LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlNpdGVBdWRp", - "dEJhY2tsb2dTbmFwc2hvdER0bxIlCh1zaXRlX2V2ZW50X2xvZ193cml0ZV9m", - "YWlsdXJlcxgWIAEoAxJHCiFvbGRlc3RfcGFya2VkX21lc3NhZ2VfYWdlX3Nl", - "Y29uZHMYFyABKAsyHC5nb29nbGUucHJvdG9idWYuRG91YmxlVmFsdWUSGgoS", - "c2NyaXB0X3F1ZXVlX2RlcHRoGBggASgFEhsKE3NjcmlwdF9idXN5X3RocmVh", - "ZHMYGSABKAUSRAoec2NyaXB0X29sZGVzdF9idXN5X2FnZV9zZWNvbmRzGBog", - "ASgLMhwuZ29vZ2xlLnByb3RvYnVmLkRvdWJsZVZhbHVlEkIKHmxvY2FsX2Ri", - "X3JlcGxpY2F0aW9uX2Nvbm5lY3RlZBgbIAEoCzIaLmdvb2dsZS5wcm90b2J1", - "Zi5Cb29sVmFsdWUSOwoWbG9jYWxfZGJfb3Bsb2dfYmFja2xvZxgcIAEoCzIb", - "Lmdvb2dsZS5wcm90b2J1Zi5JbnQ2NFZhbHVlEj0KGXN0YXJ0dXBfcmVjb25j", - "aWxlcl9hY3RpdmUYHSABKAsyGi5nb29nbGUucHJvdG9idWYuQm9vbFZhbHVl", - "Eh8KF2RldGFjaGVkX3NjcmlwdF90aHJlYWRzGB4gASgFEh0KFXNjcmlwdF9y", - "dW5fc2hlZF9jb3VudBgfIAEoBRIkChxzaXRlX3N0cmVhbV9hbGFybV9kcm9w", - "X2NvdW50GCAgASgDElgKFnNlY3JldF9taXJyb3Jfc3RhdHVzZXMYISABKAsy", - "OC5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5TZWNyZXRNaXJyb3JT", - "dGF0dXNMaXN0RHRvGnIKG0RhdGFDb25uZWN0aW9uU3RhdHVzZXNFbnRyeRIL", - "CgNrZXkYASABKAkSQgoFdmFsdWUYAiABKA4yMy5zY2FkYWJyaWRnZS5jZW50", - "cmFsY29udHJvbC52MS5Db25uZWN0aW9uSGVhbHRoRW51bToCOAEacQoYVGFn", - "UmVzb2x1dGlvbkNvdW50c0VudHJ5EgsKA2tleRgBIAEoCRJECgV2YWx1ZRgC", - "IAEoCzI1LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlRhZ1Jlc29s", - "dXRpb25TdGF0dXNEdG86AjgBGkIKIFN0b3JlQW5kRm9yd2FyZEJ1ZmZlckRl", - "cHRoc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoBToCOAEiYwoW", - "U2l0ZUhlYWx0aFJlcG9ydEFja0R0bxIPCgdzaXRlX2lkGAEgASgJEhcKD3Nl", - "cXVlbmNlX251bWJlchgCIAEoAxIQCghhY2NlcHRlZBgDIAEoCBINCgVlcnJv", - "chgEIAEoCSKLAQoMSGVhcnRiZWF0RHRvEg8KB3NpdGVfaWQYASABKAkSFQoN", - "bm9kZV9ob3N0bmFtZRgCIAEoCRIRCglpc19hY3RpdmUYAyABKAgSLQoJdGlt", - "ZXN0YW1wGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIRCglz", - "eW50aGV0aWMYBSABKAgqvQEKFENvbm5lY3Rpb25IZWFsdGhFbnVtEiEKHUNP", - "Tk5FQ1RJT05fSEVBTFRIX1VOU1BFQ0lGSUVEEAASHwobQ09OTkVDVElPTl9I", - "RUFMVEhfQ09OTkVDVEVEEAESIgoeQ09OTkVDVElPTl9IRUFMVEhfRElTQ09O", - "TkVDVEVEEAISIAocQ09OTkVDVElPTl9IRUFMVEhfQ09OTkVDVElORxADEhsK", - "F0NPTk5FQ1RJT05fSEVBTFRIX0VSUk9SEAQyoQYKFUNlbnRyYWxDb250cm9s", - "U2VydmljZRKDAQoSU3VibWl0Tm90aWZpY2F0aW9uEjQuc2NhZGFicmlkZ2Uu", - "Y2VudHJhbGNvbnRyb2wudjEuTm90aWZpY2F0aW9uU3VibWl0RHRvGjcuc2Nh", - "ZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90aWZpY2F0aW9uU3VibWl0", - "QWNrRHRvEpIBChdRdWVyeU5vdGlmaWNhdGlvblN0YXR1cxI5LnNjYWRhYnJp", - "ZGdlLmNlbnRyYWxjb250cm9sLnYxLk5vdGlmaWNhdGlvblN0YXR1c1F1ZXJ5", - "RHRvGjwuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90aWZpY2F0", - "aW9uU3RhdHVzUmVzcG9uc2VEdG8SRwoRSW5nZXN0QXVkaXRFdmVudHMSGy5z", - "aXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0", - "QWNrElAKFUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNpdGVzdHJlYW0uQ2Fj", - "aGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxKAAQoN", - "UmVjb25jaWxlU2l0ZRI2LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYx", - "LlJlY29uY2lsZVNpdGVSZXF1ZXN0RHRvGjcuc2NhZGFicmlkZ2UuY2VudHJh", - "bGNvbnRyb2wudjEuUmVjb25jaWxlU2l0ZVJlc3BvbnNlRHRvEn0KEFJlcG9y", - "dFNpdGVIZWFsdGgSMi5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5T", - "aXRlSGVhbHRoUmVwb3J0RHRvGjUuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRy", - "b2wudjEuU2l0ZUhlYWx0aFJlcG9ydEFja0R0bxJQCglIZWFydGJlYXQSKy5z", - "Y2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5IZWFydGJlYXREdG8aFi5n", - "b29nbGUucHJvdG9idWYuRW1wdHlCK6oCKFpCLk1PTS5XVy5TY2FkYUJyaWRn", - "ZS5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw==")); + "bWVzdGFtcBIVCg1vbl9kaXNrX2J5dGVzGAMgASgDEhYKDnJlamVjdGVkX2Nv", + "dW50GAQgASgFIqEBChhDb25uZWN0aW9uRW5kcG9pbnRNYXBEdG8SVQoHZW50", + "cmllcxgBIAMoCzJELnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLkNv", + "bm5lY3Rpb25FbmRwb2ludE1hcER0by5FbnRyaWVzRW50cnkaLgoMRW50cmll", + "c0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEixQEKEFRh", + "Z1F1YWxpdHlNYXBEdG8STQoHZW50cmllcxgBIAMoCzI8LnNjYWRhYnJpZGdl", + "LmNlbnRyYWxjb250cm9sLnYxLlRhZ1F1YWxpdHlNYXBEdG8uRW50cmllc0Vu", + "dHJ5GmIKDEVudHJpZXNFbnRyeRILCgNrZXkYASABKAkSQQoFdmFsdWUYAiAB", + "KAsyMi5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5UYWdRdWFsaXR5", + "Q291bnRzRHRvOgI4ASJQChFOb2RlU3RhdHVzTGlzdER0bxI7CgVub2RlcxgB", + "IAMoCzIsLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLk5vZGVTdGF0", + "dXNEdG8iPgoWU2VjcmV0TWlycm9yRmFpbHVyZUR0bxITCgtzZWNyZXRfbmFt", + "ZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIqsBChlTZWNyZXRNaXJyb3JOb2Rl", + "U3RhdHVzRHRvEhEKCW5vZGVfbmFtZRgBIAEoCRIyCg5jaGVja2VkX2F0X3V0", + "YxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASRwoIZmFpbHVy", + "ZXMYAyADKAsyNS5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5TZWNy", + "ZXRNaXJyb3JGYWlsdXJlRHRvImQKGVNlY3JldE1pcnJvclN0YXR1c0xpc3RE", + "dG8SRwoFbm9kZXMYASADKAsyOC5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJv", + "bC52MS5TZWNyZXRNaXJyb3JOb2RlU3RhdHVzRHRvIokQChNTaXRlSGVhbHRo", + "UmVwb3J0RHRvEg8KB3NpdGVfaWQYASABKAkSFwoPc2VxdWVuY2VfbnVtYmVy", + "GAIgASgDEjQKEHJlcG9ydF90aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJv", + "dG9idWYuVGltZXN0YW1wEnAKGGRhdGFfY29ubmVjdGlvbl9zdGF0dXNlcxgE", + "IAMoCzJOLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlNpdGVIZWFs", + "dGhSZXBvcnREdG8uRGF0YUNvbm5lY3Rpb25TdGF0dXNlc0VudHJ5EmoKFXRh", + "Z19yZXNvbHV0aW9uX2NvdW50cxgFIAMoCzJLLnNjYWRhYnJpZGdlLmNlbnRy", + "YWxjb250cm9sLnYxLlNpdGVIZWFsdGhSZXBvcnREdG8uVGFnUmVzb2x1dGlv", + "bkNvdW50c0VudHJ5EhoKEnNjcmlwdF9lcnJvcl9jb3VudBgGIAEoBRIkChxh", + "bGFybV9ldmFsdWF0aW9uX2Vycm9yX2NvdW50GAcgASgFEnwKH3N0b3JlX2Fu", + "ZF9mb3J3YXJkX2J1ZmZlcl9kZXB0aHMYCCADKAsyUy5zY2FkYWJyaWRnZS5j", + "ZW50cmFsY29udHJvbC52MS5TaXRlSGVhbHRoUmVwb3J0RHRvLlN0b3JlQW5k", + "Rm9yd2FyZEJ1ZmZlckRlcHRoc0VudHJ5EhkKEWRlYWRfbGV0dGVyX2NvdW50", + "GAkgASgFEh8KF2RlcGxveWVkX2luc3RhbmNlX2NvdW50GAogASgFEh4KFmVu", + "YWJsZWRfaW5zdGFuY2VfY291bnQYCyABKAUSHwoXZGlzYWJsZWRfaW5zdGFu", + "Y2VfY291bnQYDCABKAUSEQoJbm9kZV9yb2xlGA0gASgJEhUKDW5vZGVfaG9z", + "dG5hbWUYDiABKAkSWgoZZGF0YV9jb25uZWN0aW9uX2VuZHBvaW50cxgPIAEo", + "CzI3LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLkNvbm5lY3Rpb25F", + "bmRwb2ludE1hcER0bxJUChtkYXRhX2Nvbm5lY3Rpb25fdGFnX3F1YWxpdHkY", + "ECABKAsyLy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5UYWdRdWFs", + "aXR5TWFwRHRvEhwKFHBhcmtlZF9tZXNzYWdlX2NvdW50GBEgASgFEkcKDWNs", + "dXN0ZXJfbm9kZXMYEiABKAsyMC5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJv", + "bC52MS5Ob2RlU3RhdHVzTGlzdER0bxIhChlzaXRlX2F1ZGl0X3dyaXRlX2Zh", + "aWx1cmVzGBMgASgFEh8KF2F1ZGl0X3JlZGFjdGlvbl9mYWlsdXJlGBQgASgF", + "ElYKEnNpdGVfYXVkaXRfYmFja2xvZxgVIAEoCzI6LnNjYWRhYnJpZGdlLmNl", + "bnRyYWxjb250cm9sLnYxLlNpdGVBdWRpdEJhY2tsb2dTbmFwc2hvdER0bxIl", + "Ch1zaXRlX2V2ZW50X2xvZ193cml0ZV9mYWlsdXJlcxgWIAEoAxJHCiFvbGRl", + "c3RfcGFya2VkX21lc3NhZ2VfYWdlX3NlY29uZHMYFyABKAsyHC5nb29nbGUu", + "cHJvdG9idWYuRG91YmxlVmFsdWUSGgoSc2NyaXB0X3F1ZXVlX2RlcHRoGBgg", + "ASgFEhsKE3NjcmlwdF9idXN5X3RocmVhZHMYGSABKAUSRAoec2NyaXB0X29s", + "ZGVzdF9idXN5X2FnZV9zZWNvbmRzGBogASgLMhwuZ29vZ2xlLnByb3RvYnVm", + "LkRvdWJsZVZhbHVlEkIKHmxvY2FsX2RiX3JlcGxpY2F0aW9uX2Nvbm5lY3Rl", + "ZBgbIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5Cb29sVmFsdWUSOwoWbG9jYWxf", + "ZGJfb3Bsb2dfYmFja2xvZxgcIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQ2", + "NFZhbHVlEj0KGXN0YXJ0dXBfcmVjb25jaWxlcl9hY3RpdmUYHSABKAsyGi5n", + "b29nbGUucHJvdG9idWYuQm9vbFZhbHVlEh8KF2RldGFjaGVkX3NjcmlwdF90", + "aHJlYWRzGB4gASgFEh0KFXNjcmlwdF9ydW5fc2hlZF9jb3VudBgfIAEoBRIk", + "ChxzaXRlX3N0cmVhbV9hbGFybV9kcm9wX2NvdW50GCAgASgDElgKFnNlY3Jl", + "dF9taXJyb3Jfc3RhdHVzZXMYISABKAsyOC5zY2FkYWJyaWRnZS5jZW50cmFs", + "Y29udHJvbC52MS5TZWNyZXRNaXJyb3JTdGF0dXNMaXN0RHRvGnIKG0RhdGFD", + "b25uZWN0aW9uU3RhdHVzZXNFbnRyeRILCgNrZXkYASABKAkSQgoFdmFsdWUY", + "AiABKA4yMy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5Db25uZWN0", + "aW9uSGVhbHRoRW51bToCOAEacQoYVGFnUmVzb2x1dGlvbkNvdW50c0VudHJ5", + "EgsKA2tleRgBIAEoCRJECgV2YWx1ZRgCIAEoCzI1LnNjYWRhYnJpZGdlLmNl", + "bnRyYWxjb250cm9sLnYxLlRhZ1Jlc29sdXRpb25TdGF0dXNEdG86AjgBGkIK", + "IFN0b3JlQW5kRm9yd2FyZEJ1ZmZlckRlcHRoc0VudHJ5EgsKA2tleRgBIAEo", + "CRINCgV2YWx1ZRgCIAEoBToCOAEiYwoWU2l0ZUhlYWx0aFJlcG9ydEFja0R0", + "bxIPCgdzaXRlX2lkGAEgASgJEhcKD3NlcXVlbmNlX251bWJlchgCIAEoAxIQ", + "CghhY2NlcHRlZBgDIAEoCBINCgVlcnJvchgEIAEoCSKLAQoMSGVhcnRiZWF0", + "RHRvEg8KB3NpdGVfaWQYASABKAkSFQoNbm9kZV9ob3N0bmFtZRgCIAEoCRIR", + "Cglpc19hY3RpdmUYAyABKAgSLQoJdGltZXN0YW1wGAQgASgLMhouZ29vZ2xl", + "LnByb3RvYnVmLlRpbWVzdGFtcBIRCglzeW50aGV0aWMYBSABKAgqvQEKFENv", + "bm5lY3Rpb25IZWFsdGhFbnVtEiEKHUNPTk5FQ1RJT05fSEVBTFRIX1VOU1BF", + "Q0lGSUVEEAASHwobQ09OTkVDVElPTl9IRUFMVEhfQ09OTkVDVEVEEAESIgoe", + "Q09OTkVDVElPTl9IRUFMVEhfRElTQ09OTkVDVEVEEAISIAocQ09OTkVDVElP", + "Tl9IRUFMVEhfQ09OTkVDVElORxADEhsKF0NPTk5FQ1RJT05fSEVBTFRIX0VS", + "Uk9SEAQyoQYKFUNlbnRyYWxDb250cm9sU2VydmljZRKDAQoSU3VibWl0Tm90", + "aWZpY2F0aW9uEjQuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90", + "aWZpY2F0aW9uU3VibWl0RHRvGjcuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRy", + "b2wudjEuTm90aWZpY2F0aW9uU3VibWl0QWNrRHRvEpIBChdRdWVyeU5vdGlm", + "aWNhdGlvblN0YXR1cxI5LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYx", + "Lk5vdGlmaWNhdGlvblN0YXR1c1F1ZXJ5RHRvGjwuc2NhZGFicmlkZ2UuY2Vu", + "dHJhbGNvbnRyb2wudjEuTm90aWZpY2F0aW9uU3RhdHVzUmVzcG9uc2VEdG8S", + "RwoRSW5nZXN0QXVkaXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRC", + "YXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrElAKFUluZ2VzdENhY2hlZFRl", + "bGVtZXRyeRIgLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5z", + "aXRlc3RyZWFtLkluZ2VzdEFjaxKAAQoNUmVjb25jaWxlU2l0ZRI2LnNjYWRh", + "YnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlJlY29uY2lsZVNpdGVSZXF1ZXN0", + "RHRvGjcuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuUmVjb25jaWxl", + "U2l0ZVJlc3BvbnNlRHRvEn0KEFJlcG9ydFNpdGVIZWFsdGgSMi5zY2FkYWJy", + "aWRnZS5jZW50cmFsY29udHJvbC52MS5TaXRlSGVhbHRoUmVwb3J0RHRvGjUu", + "c2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuU2l0ZUhlYWx0aFJlcG9y", + "dEFja0R0bxJQCglIZWFydGJlYXQSKy5zY2FkYWJyaWRnZS5jZW50cmFsY29u", + "dHJvbC52MS5IZWFydGJlYXREdG8aFi5nb29nbGUucHJvdG9idWYuRW1wdHlC", + "K6oCKFpCLk1PTS5XVy5TY2FkYUJyaWRnZS5Db21tdW5pY2F0aW9uLkdycGNi", + "BnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.EmptyReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionHealthEnum), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -168,7 +169,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagResolutionStatusDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagResolutionStatusDto.Parser, new[]{ "TotalSubscribed", "SuccessfullyResolved" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityCountsDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityCountsDto.Parser, new[]{ "Good", "Bad", "Uncertain" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusDto.Parser, new[]{ "Hostname", "IsOnline", "Role" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto.Parser, new[]{ "PendingCount", "OldestPendingUtc", "OnDiskBytes" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto.Parser, new[]{ "PendingCount", "OldestPendingUtc", "OnDiskBytes", "RejectedCount" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto.Parser, new[]{ "Entries" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto.Parser, new[]{ "Entries" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto.Parser, new[]{ "Nodes" }, null, null, null, null), @@ -3408,6 +3409,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { pendingCount_ = other.pendingCount_; oldestPendingUtc_ = other.oldestPendingUtc_ != null ? other.oldestPendingUtc_.Clone() : null; onDiskBytes_ = other.onDiskBytes_; + rejectedCount_ = other.rejectedCount_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -3456,6 +3458,23 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { } } + /// Field number for the "rejected_count" field. + public const int RejectedCountFieldNumber = 4; + private int rejectedCount_; + /// + /// Rows central permanently refused and the site moved to the Rejected forward + /// state (a subset of pending_count). Additive; an older site omits it (reads 0) + /// and an older central ignores it (Gitea #69). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int RejectedCount { + get { return rejectedCount_; } + set { + rejectedCount_ = value; + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -3474,6 +3493,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (PendingCount != other.PendingCount) return false; if (!object.Equals(OldestPendingUtc, other.OldestPendingUtc)) return false; if (OnDiskBytes != other.OnDiskBytes) return false; + if (RejectedCount != other.RejectedCount) return false; return Equals(_unknownFields, other._unknownFields); } @@ -3484,6 +3504,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (PendingCount != 0) hash ^= PendingCount.GetHashCode(); if (oldestPendingUtc_ != null) hash ^= OldestPendingUtc.GetHashCode(); if (OnDiskBytes != 0L) hash ^= OnDiskBytes.GetHashCode(); + if (RejectedCount != 0) hash ^= RejectedCount.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -3514,6 +3535,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { output.WriteRawTag(24); output.WriteInt64(OnDiskBytes); } + if (RejectedCount != 0) { + output.WriteRawTag(32); + output.WriteInt32(RejectedCount); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -3536,6 +3561,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { output.WriteRawTag(24); output.WriteInt64(OnDiskBytes); } + if (RejectedCount != 0) { + output.WriteRawTag(32); + output.WriteInt32(RejectedCount); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -3555,6 +3584,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (OnDiskBytes != 0L) { size += 1 + pb::CodedOutputStream.ComputeInt64Size(OnDiskBytes); } + if (RejectedCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(RejectedCount); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -3579,6 +3611,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (other.OnDiskBytes != 0L) { OnDiskBytes = other.OnDiskBytes; } + if (other.RejectedCount != 0) { + RejectedCount = other.RejectedCount; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -3613,6 +3648,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { OnDiskBytes = input.ReadInt64(); break; } + case 32: { + RejectedCount = input.ReadInt32(); + break; + } } } #endif @@ -3647,6 +3686,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { OnDiskBytes = input.ReadInt64(); break; } + case 32: { + RejectedCount = input.ReadInt32(); + break; + } } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs index 2f555e3c..77885a5d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs @@ -581,6 +581,7 @@ public static class CentralControlDtoMapper { PendingCount = backlog.PendingCount, OnDiskBytes = backlog.OnDiskBytes, + RejectedCount = backlog.RejectedCount, }; if (backlog.OldestPendingUtc.HasValue) @@ -675,7 +676,8 @@ public static class CentralControlDtoMapper OldestPendingUtc: snapshot.OldestPendingUtc is null ? null : DateTime.SpecifyKind(snapshot.OldestPendingUtc.ToDateTime(), DateTimeKind.Utc), - OnDiskBytes: snapshot.OnDiskBytes); + OnDiskBytes: snapshot.OnDiskBytes, + RejectedCount: snapshot.RejectedCount); } List? mirrorStatuses = null; diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto index e092b3f9..88c4c56a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto @@ -170,6 +170,10 @@ message SiteAuditBacklogSnapshotDto { int32 pending_count = 1; google.protobuf.Timestamp oldest_pending_utc = 2; // absent when the queue is empty int64 on_disk_bytes = 3; + // Rows central permanently refused and the site moved to the Rejected forward + // state (a subset of pending_count). Additive; an older site omits it (reads 0) + // and an older central ignores it (Gitea #69). + int32 rejected_count = 4; } // The three collection wrappers below exist so null and empty stay diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterForwardStateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterForwardStateTests.cs index e07a4852..aebc104d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterForwardStateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterForwardStateTests.cs @@ -133,6 +133,29 @@ public class SqliteAuditWriterForwardStateTests : IDisposable Assert.Equal(1, writer.RejectedPushRowCount); } + /// + /// The backlog snapshot reports permanently refused rows via RejectedCount (Gitea #69): + /// a Rejected row is still owed (counted in PendingCount) but is also counted separately, + /// and rows still Pending do not inflate it. + /// + /// A task that represents the asynchronous operation. + [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 + } + /// /// 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index b97bc2e8..0e6c58b2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -621,6 +621,34 @@ public class HealthPageTests : BunitContext }); } + /// Verifies the audit-rejected badge renders only when the site reports rejected audit rows (Gitea #69). + /// The reported rejected-row count, or null for no backlog snapshot. + /// Whether the badge should render. + [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\"")); + } + /// Verifies that no secret mirror warning renders when no node is failing. /// Whether the site reports secret mirror status at all. [Theory] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs index 9fb89fde..ff20ba1a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs @@ -466,6 +466,38 @@ public class CentralControlDtoMapperTests Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds); } + /// Verifies that the backlog's rejected-row count survives the wire round trip (Gitea #69). + [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); + } + + /// + /// Backward compatibility: a backlog DTO from a pre-#69 site (no rejected_count field on the wire) + /// decodes to RejectedCount 0. + /// + [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); + } + /// Verifies that a backlog report with an empty queue keeps a null oldest-pending value. [Fact] public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending()