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 8ecaed72..487268d7 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
@@ -275,6 +275,14 @@
No self-heal
}
+ @if (HasSecretMirrorFailures(state))
+ {
+
+ Secret mirror failing
+
+ }
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
{
offline since @changedAt.ToString("u")
@@ -302,6 +310,26 @@
@if (state.LatestReport != null)
{
var report = state.LatestReport;
+ @if (HasSecretMirrorFailures(state))
+ {
+ @* Review finding M1: every site node's deployed-secrets mirror failures,
+ the standby's included (carried on the active node's report). *@
+
@* Each logical group below sits in its own card, with the group's
head promoted to a real card-header, instead of h6 rules stacked
@@ -590,6 +618,15 @@
state.IsOnline
&& state.LatestReport?.StartupReconcilerActive == false;
+ // Review finding M1: warn when any site node (the standby included) reports deployed
+ // secrets it cannot apply/decrypt. Null ("no data": central self-report, pre-M1 site) and
+ // nodes with an empty failure list are healthy. Offline sites are skipped like the other
+ // warning badges — the Offline badge is the signal that matters there.
+ private static bool HasSecretMirrorFailures(SiteHealthState state) =>
+ state.IsOnline
+ && state.LatestReport?.SecretMirrorStatuses is { } statuses
+ && statuses.Any(n => n.Failures.Count > 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/Messages/Health/SecretMirrorNodeStatus.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SecretMirrorNodeStatus.cs
new file mode 100644
index 00000000..094c750b
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SecretMirrorNodeStatus.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
+
+///
+/// One site node's latest deployed-secrets mirror outcome, as carried on
+/// (review finding M1).
+///
+///
+/// Every site node runs its own DeployedSecretsMirror and records its current
+/// failure set in the LocalDb-replicated secret_mirror_status table, keyed by node
+/// name; the ACTIVE node reads every row and ships them on its health report, so a
+/// STANDBY node's failures (most importantly a master-key mismatch that stops a deployed
+/// secret decrypting there) reach central before a failover exposes them. Carries names
+/// and human-readable messages only — never a secret value or ciphertext.
+///
+///
The reporting node's semantic name (
node-a/
node-b), or its host name when unconfigured.
+///
When that node last recorded a CHANGE in its failure set (or its first pass after start-up) — not the time of its latest pass.
+///
That node's current mirror failures; empty when the node's mirror is healthy.
+public sealed record SecretMirrorNodeStatus(
+ string NodeName,
+ DateTimeOffset CheckedAtUtc,
+ IReadOnlyList
Failures);
+
+/// A single deployed secret that failed to apply and/or decrypt on one site node.
+/// The secret's name.
+/// A human-readable failure message — never includes the secret's value.
+public sealed record SecretMirrorFailure(string SecretName, string Message);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs
index 346dd034..7525ebd0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs
@@ -173,6 +173,20 @@ public record SiteHealthReport(
///
///
public bool? StartupReconcilerActive { get; init; }
+
+ ///
+ /// Every site node's latest deployed-secrets mirror outcome (review finding M1) — one
+ /// entry per node that has recorded a row in the replicated secret_mirror_status
+ /// table, including the STANDBY node, whose own health report is never sent.
+ ///
+ ///
+ /// Null = no data: a central self-report, a pre-M1 site build, a site whose
+ /// reporter has not run yet, or a failed read. An EMPTY list means the feature is wired
+ /// but no node has recorded a status yet. A node entry with an empty
+ /// list is a healthy mirror. Names and
+ /// messages only — never secret values or ciphertext.
+ ///
+ public IReadOnlyList? SecretMirrorStatuses { get; init; }
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs
index 9e3f8b48..48fd90fe 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs
@@ -72,80 +72,89 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
"ZXkYASABKAkSQQoFdmFsdWUYAiABKAsyMi5zY2FkYWJyaWRnZS5jZW50cmFs",
"Y29udHJvbC52MS5UYWdRdWFsaXR5Q291bnRzRHRvOgI4ASJQChFOb2RlU3Rh",
"dHVzTGlzdER0bxI7CgVub2RlcxgBIAMoCzIsLnNjYWRhYnJpZGdlLmNlbnRy",
- "YWxjb250cm9sLnYxLk5vZGVTdGF0dXNEdG8irw8KE1NpdGVIZWFsdGhSZXBv",
- "cnREdG8SDwoHc2l0ZV9pZBgBIAEoCRIXCg9zZXF1ZW5jZV9udW1iZXIYAiAB",
- "KAMSNAoQcmVwb3J0X3RpbWVzdGFtcBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1",
- "Zi5UaW1lc3RhbXAScAoYZGF0YV9jb25uZWN0aW9uX3N0YXR1c2VzGAQgAygL",
- "Mk4uc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuU2l0ZUhlYWx0aFJl",
- "cG9ydER0by5EYXRhQ29ubmVjdGlvblN0YXR1c2VzRW50cnkSagoVdGFnX3Jl",
- "c29sdXRpb25fY291bnRzGAUgAygLMksuc2NhZGFicmlkZ2UuY2VudHJhbGNv",
- "bnRyb2wudjEuU2l0ZUhlYWx0aFJlcG9ydER0by5UYWdSZXNvbHV0aW9uQ291",
- "bnRzRW50cnkSGgoSc2NyaXB0X2Vycm9yX2NvdW50GAYgASgFEiQKHGFsYXJt",
- "X2V2YWx1YXRpb25fZXJyb3JfY291bnQYByABKAUSfAofc3RvcmVfYW5kX2Zv",
- "cndhcmRfYnVmZmVyX2RlcHRocxgIIAMoCzJTLnNjYWRhYnJpZGdlLmNlbnRy",
- "YWxjb250cm9sLnYxLlNpdGVIZWFsdGhSZXBvcnREdG8uU3RvcmVBbmRGb3J3",
- "YXJkQnVmZmVyRGVwdGhzRW50cnkSGQoRZGVhZF9sZXR0ZXJfY291bnQYCSAB",
- "KAUSHwoXZGVwbG95ZWRfaW5zdGFuY2VfY291bnQYCiABKAUSHgoWZW5hYmxl",
- "ZF9pbnN0YW5jZV9jb3VudBgLIAEoBRIfChdkaXNhYmxlZF9pbnN0YW5jZV9j",
- "b3VudBgMIAEoBRIRCglub2RlX3JvbGUYDSABKAkSFQoNbm9kZV9ob3N0bmFt",
- "ZRgOIAEoCRJaChlkYXRhX2Nvbm5lY3Rpb25fZW5kcG9pbnRzGA8gASgLMjcu",
- "c2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuQ29ubmVjdGlvbkVuZHBv",
- "aW50TWFwRHRvElQKG2RhdGFfY29ubmVjdGlvbl90YWdfcXVhbGl0eRgQIAEo",
- "CzIvLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlRhZ1F1YWxpdHlN",
- "YXBEdG8SHAoUcGFya2VkX21lc3NhZ2VfY291bnQYESABKAUSRwoNY2x1c3Rl",
- "cl9ub2RlcxgSIAEoCzIwLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYx",
- "Lk5vZGVTdGF0dXNMaXN0RHRvEiEKGXNpdGVfYXVkaXRfd3JpdGVfZmFpbHVy",
- "ZXMYEyABKAUSHwoXYXVkaXRfcmVkYWN0aW9uX2ZhaWx1cmUYFCABKAUSVgoS",
- "c2l0ZV9hdWRpdF9iYWNrbG9nGBUgASgLMjouc2NhZGFicmlkZ2UuY2VudHJh",
- "bGNvbnRyb2wudjEuU2l0ZUF1ZGl0QmFja2xvZ1NuYXBzaG90RHRvEiUKHXNp",
- "dGVfZXZlbnRfbG9nX3dyaXRlX2ZhaWx1cmVzGBYgASgDEkcKIW9sZGVzdF9w",
- "YXJrZWRfbWVzc2FnZV9hZ2Vfc2Vjb25kcxgXIAEoCzIcLmdvb2dsZS5wcm90",
- "b2J1Zi5Eb3VibGVWYWx1ZRIaChJzY3JpcHRfcXVldWVfZGVwdGgYGCABKAUS",
- "GwoTc2NyaXB0X2J1c3lfdGhyZWFkcxgZIAEoBRJECh5zY3JpcHRfb2xkZXN0",
- "X2J1c3lfYWdlX3NlY29uZHMYGiABKAsyHC5nb29nbGUucHJvdG9idWYuRG91",
- "YmxlVmFsdWUSQgoebG9jYWxfZGJfcmVwbGljYXRpb25fY29ubmVjdGVkGBsg",
- "ASgLMhouZ29vZ2xlLnByb3RvYnVmLkJvb2xWYWx1ZRI7ChZsb2NhbF9kYl9v",
- "cGxvZ19iYWNrbG9nGBwgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDY0VmFs",
- "dWUSPQoZc3RhcnR1cF9yZWNvbmNpbGVyX2FjdGl2ZRgdIAEoCzIaLmdvb2ds",
- "ZS5wcm90b2J1Zi5Cb29sVmFsdWUSHwoXZGV0YWNoZWRfc2NyaXB0X3RocmVh",
- "ZHMYHiABKAUSHQoVc2NyaXB0X3J1bl9zaGVkX2NvdW50GB8gASgFEiQKHHNp",
- "dGVfc3RyZWFtX2FsYXJtX2Ryb3BfY291bnQYICABKAMacgobRGF0YUNvbm5l",
- "Y3Rpb25TdGF0dXNlc0VudHJ5EgsKA2tleRgBIAEoCRJCCgV2YWx1ZRgCIAEo",
- "DjIzLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLkNvbm5lY3Rpb25I",
- "ZWFsdGhFbnVtOgI4ARpxChhUYWdSZXNvbHV0aW9uQ291bnRzRW50cnkSCwoD",
- "a2V5GAEgASgJEkQKBXZhbHVlGAIgASgLMjUuc2NhZGFicmlkZ2UuY2VudHJh",
- "bGNvbnRyb2wudjEuVGFnUmVzb2x1dGlvblN0YXR1c0R0bzoCOAEaQgogU3Rv",
- "cmVBbmRGb3J3YXJkQnVmZmVyRGVwdGhzRW50cnkSCwoDa2V5GAEgASgJEg0K",
- "BXZhbHVlGAIgASgFOgI4ASJjChZTaXRlSGVhbHRoUmVwb3J0QWNrRHRvEg8K",
- "B3NpdGVfaWQYASABKAkSFwoPc2VxdWVuY2VfbnVtYmVyGAIgASgDEhAKCGFj",
- "Y2VwdGVkGAMgASgIEg0KBWVycm9yGAQgASgJIosBCgxIZWFydGJlYXREdG8S",
- "DwoHc2l0ZV9pZBgBIAEoCRIVCg1ub2RlX2hvc3RuYW1lGAIgASgJEhEKCWlz",
- "X2FjdGl2ZRgDIAEoCBItCgl0aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJv",
- "dG9idWYuVGltZXN0YW1wEhEKCXN5bnRoZXRpYxgFIAEoCCq9AQoUQ29ubmVj",
- "dGlvbkhlYWx0aEVudW0SIQodQ09OTkVDVElPTl9IRUFMVEhfVU5TUEVDSUZJ",
- "RUQQABIfChtDT05ORUNUSU9OX0hFQUxUSF9DT05ORUNURUQQARIiCh5DT05O",
- "RUNUSU9OX0hFQUxUSF9ESVNDT05ORUNURUQQAhIgChxDT05ORUNUSU9OX0hF",
- "QUxUSF9DT05ORUNUSU5HEAMSGwoXQ09OTkVDVElPTl9IRUFMVEhfRVJST1IQ",
- "BDKhBgoVQ2VudHJhbENvbnRyb2xTZXJ2aWNlEoMBChJTdWJtaXROb3RpZmlj",
- "YXRpb24SNC5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5Ob3RpZmlj",
- "YXRpb25TdWJtaXREdG8aNy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52",
- "MS5Ob3RpZmljYXRpb25TdWJtaXRBY2tEdG8SkgEKF1F1ZXJ5Tm90aWZpY2F0",
- "aW9uU3RhdHVzEjkuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90",
- "aWZpY2F0aW9uU3RhdHVzUXVlcnlEdG8aPC5zY2FkYWJyaWRnZS5jZW50cmFs",
- "Y29udHJvbC52MS5Ob3RpZmljYXRpb25TdGF0dXNSZXNwb25zZUR0bxJHChFJ",
- "bmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVzdHJlYW0uQXVkaXRFdmVudEJhdGNo",
- "GhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sSUAoVSW5nZXN0Q2FjaGVkVGVsZW1l",
- "dHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRUZWxlbWV0cnlCYXRjaBoVLnNpdGVz",
- "dHJlYW0uSW5nZXN0QWNrEoABCg1SZWNvbmNpbGVTaXRlEjYuc2NhZGFicmlk",
- "Z2UuY2VudHJhbGNvbnRyb2wudjEuUmVjb25jaWxlU2l0ZVJlcXVlc3REdG8a",
- "Ny5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5SZWNvbmNpbGVTaXRl",
- "UmVzcG9uc2VEdG8SfQoQUmVwb3J0U2l0ZUhlYWx0aBIyLnNjYWRhYnJpZGdl",
- "LmNlbnRyYWxjb250cm9sLnYxLlNpdGVIZWFsdGhSZXBvcnREdG8aNS5zY2Fk",
- "YWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5TaXRlSGVhbHRoUmVwb3J0QWNr",
- "RHRvElAKCUhlYXJ0YmVhdBIrLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9s",
- "LnYxLkhlYXJ0YmVhdER0bxoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eUIrqgIo",
- "WkIuTU9NLldXLlNjYWRhQnJpZGdlLkNvbW11bmljYXRpb24uR3JwY2IGcHJv",
- "dG8z"));
+ "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=="));
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[] {
@@ -163,7 +172,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
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),
- new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser, new[]{ "SiteId", "SequenceNumber", "ReportTimestamp", "DataConnectionStatuses", "TagResolutionCounts", "ScriptErrorCount", "AlarmEvaluationErrorCount", "StoreAndForwardBufferDepths", "DeadLetterCount", "DeployedInstanceCount", "EnabledInstanceCount", "DisabledInstanceCount", "NodeRole", "NodeHostname", "DataConnectionEndpoints", "DataConnectionTagQuality", "ParkedMessageCount", "ClusterNodes", "SiteAuditWriteFailures", "AuditRedactionFailure", "SiteAuditBacklog", "SiteEventLogWriteFailures", "OldestParkedMessageAgeSeconds", "ScriptQueueDepth", "ScriptBusyThreads", "ScriptOldestBusyAgeSeconds", "LocalDbReplicationConnected", "LocalDbOplogBacklog", "StartupReconcilerActive", "DetachedScriptThreads", "ScriptRunShedCount", "SiteStreamAlarmDropCount" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, null, null, }),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorFailureDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorFailureDto.Parser, new[]{ "SecretName", "Message" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorNodeStatusDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorNodeStatusDto.Parser, new[]{ "NodeName", "CheckedAtUtc", "Failures" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto.Parser, new[]{ "Nodes" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser, new[]{ "SiteId", "SequenceNumber", "ReportTimestamp", "DataConnectionStatuses", "TagResolutionCounts", "ScriptErrorCount", "AlarmEvaluationErrorCount", "StoreAndForwardBufferDepths", "DeadLetterCount", "DeployedInstanceCount", "EnabledInstanceCount", "DisabledInstanceCount", "NodeRole", "NodeHostname", "DataConnectionEndpoints", "DataConnectionTagQuality", "ParkedMessageCount", "ClusterNodes", "SiteAuditWriteFailures", "AuditRedactionFailure", "SiteAuditBacklog", "SiteEventLogWriteFailures", "OldestParkedMessageAgeSeconds", "ScriptQueueDepth", "ScriptBusyThreads", "ScriptOldestBusyAgeSeconds", "LocalDbReplicationConnected", "LocalDbOplogBacklog", "StartupReconcilerActive", "DetachedScriptThreads", "ScriptRunShedCount", "SiteStreamAlarmDropCount", "SecretMirrorStatuses" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, null, null, }),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto.Parser, new[]{ "SiteId", "SequenceNumber", "Accepted", "Error" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto.Parser, new[]{ "SiteId", "NodeHostname", "IsActive", "Timestamp", "Synthetic" }, null, null, null, null)
}));
@@ -4203,6 +4215,706 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
+ ///
+ /// Review finding M1: one site node's deployed-secrets mirror failures. Names and
+ /// human-readable messages only — never a secret value or ciphertext.
+ ///
+ [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
+ public sealed partial class SecretMirrorFailureDto : pb::IMessage
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ , pb::IBufferMessage
+ #endif
+ {
+ private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SecretMirrorFailureDto());
+ private pb::UnknownFieldSet _unknownFields;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pb::MessageParser Parser { get { return _parser; } }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pbr::MessageDescriptor Descriptor {
+ get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[14]; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ pbr::MessageDescriptor pb::IMessage.Descriptor {
+ get { return Descriptor; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorFailureDto() {
+ OnConstruction();
+ }
+
+ partial void OnConstruction();
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorFailureDto(SecretMirrorFailureDto other) : this() {
+ secretName_ = other.secretName_;
+ message_ = other.message_;
+ _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorFailureDto Clone() {
+ return new SecretMirrorFailureDto(this);
+ }
+
+ /// Field number for the "secret_name" field.
+ public const int SecretNameFieldNumber = 1;
+ private string secretName_ = "";
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string SecretName {
+ get { return secretName_; }
+ set {
+ secretName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "message" field.
+ public const int MessageFieldNumber = 2;
+ private string message_ = "";
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string Message {
+ get { return message_; }
+ set {
+ message_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override bool Equals(object other) {
+ return Equals(other as SecretMirrorFailureDto);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool Equals(SecretMirrorFailureDto other) {
+ if (ReferenceEquals(other, null)) {
+ return false;
+ }
+ if (ReferenceEquals(other, this)) {
+ return true;
+ }
+ if (SecretName != other.SecretName) return false;
+ if (Message != other.Message) return false;
+ return Equals(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override int GetHashCode() {
+ int hash = 1;
+ if (SecretName.Length != 0) hash ^= SecretName.GetHashCode();
+ if (Message.Length != 0) hash ^= Message.GetHashCode();
+ if (_unknownFields != null) {
+ hash ^= _unknownFields.GetHashCode();
+ }
+ return hash;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override string ToString() {
+ return pb::JsonFormatter.ToDiagnosticString(this);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void WriteTo(pb::CodedOutputStream output) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ output.WriteRawMessage(this);
+ #else
+ if (SecretName.Length != 0) {
+ output.WriteRawTag(10);
+ output.WriteString(SecretName);
+ }
+ if (Message.Length != 0) {
+ output.WriteRawTag(18);
+ output.WriteString(Message);
+ }
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(output);
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+ if (SecretName.Length != 0) {
+ output.WriteRawTag(10);
+ output.WriteString(SecretName);
+ }
+ if (Message.Length != 0) {
+ output.WriteRawTag(18);
+ output.WriteString(Message);
+ }
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(ref output);
+ }
+ }
+ #endif
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int CalculateSize() {
+ int size = 0;
+ if (SecretName.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(SecretName);
+ }
+ if (Message.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(Message);
+ }
+ if (_unknownFields != null) {
+ size += _unknownFields.CalculateSize();
+ }
+ return size;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(SecretMirrorFailureDto other) {
+ if (other == null) {
+ return;
+ }
+ if (other.SecretName.Length != 0) {
+ SecretName = other.SecretName;
+ }
+ if (other.Message.Length != 0) {
+ Message = other.Message;
+ }
+ _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(pb::CodedInputStream input) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ input.ReadRawMessage(this);
+ #else
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+ break;
+ case 10: {
+ SecretName = input.ReadString();
+ break;
+ }
+ case 18: {
+ Message = input.ReadString();
+ break;
+ }
+ }
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+ break;
+ case 10: {
+ SecretName = input.ReadString();
+ break;
+ }
+ case 18: {
+ Message = input.ReadString();
+ break;
+ }
+ }
+ }
+ }
+ #endif
+
+ }
+
+ [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
+ public sealed partial class SecretMirrorNodeStatusDto : pb::IMessage
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ , pb::IBufferMessage
+ #endif
+ {
+ private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SecretMirrorNodeStatusDto());
+ private pb::UnknownFieldSet _unknownFields;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pb::MessageParser Parser { get { return _parser; } }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pbr::MessageDescriptor Descriptor {
+ get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[15]; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ pbr::MessageDescriptor pb::IMessage.Descriptor {
+ get { return Descriptor; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorNodeStatusDto() {
+ OnConstruction();
+ }
+
+ partial void OnConstruction();
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorNodeStatusDto(SecretMirrorNodeStatusDto other) : this() {
+ nodeName_ = other.nodeName_;
+ checkedAtUtc_ = other.checkedAtUtc_ != null ? other.checkedAtUtc_.Clone() : null;
+ failures_ = other.failures_.Clone();
+ _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorNodeStatusDto Clone() {
+ return new SecretMirrorNodeStatusDto(this);
+ }
+
+ /// Field number for the "node_name" field.
+ public const int NodeNameFieldNumber = 1;
+ private string nodeName_ = "";
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string NodeName {
+ get { return nodeName_; }
+ set {
+ nodeName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "checked_at_utc" field.
+ public const int CheckedAtUtcFieldNumber = 2;
+ private global::Google.Protobuf.WellKnownTypes.Timestamp checkedAtUtc_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::Google.Protobuf.WellKnownTypes.Timestamp CheckedAtUtc {
+ get { return checkedAtUtc_; }
+ set {
+ checkedAtUtc_ = value;
+ }
+ }
+
+ /// Field number for the "failures" field.
+ public const int FailuresFieldNumber = 3;
+ private static readonly pb::FieldCodec _repeated_failures_codec
+ = pb::FieldCodec.ForMessage(26, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorFailureDto.Parser);
+ private readonly pbc::RepeatedField failures_ = new pbc::RepeatedField();
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public pbc::RepeatedField Failures {
+ get { return failures_; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override bool Equals(object other) {
+ return Equals(other as SecretMirrorNodeStatusDto);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool Equals(SecretMirrorNodeStatusDto other) {
+ if (ReferenceEquals(other, null)) {
+ return false;
+ }
+ if (ReferenceEquals(other, this)) {
+ return true;
+ }
+ if (NodeName != other.NodeName) return false;
+ if (!object.Equals(CheckedAtUtc, other.CheckedAtUtc)) return false;
+ if(!failures_.Equals(other.failures_)) return false;
+ return Equals(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override int GetHashCode() {
+ int hash = 1;
+ if (NodeName.Length != 0) hash ^= NodeName.GetHashCode();
+ if (checkedAtUtc_ != null) hash ^= CheckedAtUtc.GetHashCode();
+ hash ^= failures_.GetHashCode();
+ if (_unknownFields != null) {
+ hash ^= _unknownFields.GetHashCode();
+ }
+ return hash;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override string ToString() {
+ return pb::JsonFormatter.ToDiagnosticString(this);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void WriteTo(pb::CodedOutputStream output) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ output.WriteRawMessage(this);
+ #else
+ if (NodeName.Length != 0) {
+ output.WriteRawTag(10);
+ output.WriteString(NodeName);
+ }
+ if (checkedAtUtc_ != null) {
+ output.WriteRawTag(18);
+ output.WriteMessage(CheckedAtUtc);
+ }
+ failures_.WriteTo(output, _repeated_failures_codec);
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(output);
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+ if (NodeName.Length != 0) {
+ output.WriteRawTag(10);
+ output.WriteString(NodeName);
+ }
+ if (checkedAtUtc_ != null) {
+ output.WriteRawTag(18);
+ output.WriteMessage(CheckedAtUtc);
+ }
+ failures_.WriteTo(ref output, _repeated_failures_codec);
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(ref output);
+ }
+ }
+ #endif
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int CalculateSize() {
+ int size = 0;
+ if (NodeName.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(NodeName);
+ }
+ if (checkedAtUtc_ != null) {
+ size += 1 + pb::CodedOutputStream.ComputeMessageSize(CheckedAtUtc);
+ }
+ size += failures_.CalculateSize(_repeated_failures_codec);
+ if (_unknownFields != null) {
+ size += _unknownFields.CalculateSize();
+ }
+ return size;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(SecretMirrorNodeStatusDto other) {
+ if (other == null) {
+ return;
+ }
+ if (other.NodeName.Length != 0) {
+ NodeName = other.NodeName;
+ }
+ if (other.checkedAtUtc_ != null) {
+ if (checkedAtUtc_ == null) {
+ CheckedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ CheckedAtUtc.MergeFrom(other.CheckedAtUtc);
+ }
+ failures_.Add(other.failures_);
+ _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(pb::CodedInputStream input) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ input.ReadRawMessage(this);
+ #else
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+ break;
+ case 10: {
+ NodeName = input.ReadString();
+ break;
+ }
+ case 18: {
+ if (checkedAtUtc_ == null) {
+ CheckedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(CheckedAtUtc);
+ break;
+ }
+ case 26: {
+ failures_.AddEntriesFrom(input, _repeated_failures_codec);
+ break;
+ }
+ }
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+ break;
+ case 10: {
+ NodeName = input.ReadString();
+ break;
+ }
+ case 18: {
+ if (checkedAtUtc_ == null) {
+ CheckedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(CheckedAtUtc);
+ break;
+ }
+ case 26: {
+ failures_.AddEntriesFrom(ref input, _repeated_failures_codec);
+ break;
+ }
+ }
+ }
+ }
+ #endif
+
+ }
+
+ ///
+ /// Presence wrapper (same reason as NodeStatusListDto): absent = null ("no data"),
+ /// present-but-empty = the feature is wired and no node has recorded a status yet.
+ ///
+ [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
+ public sealed partial class SecretMirrorStatusListDto : pb::IMessage
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ , pb::IBufferMessage
+ #endif
+ {
+ private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SecretMirrorStatusListDto());
+ private pb::UnknownFieldSet _unknownFields;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pb::MessageParser Parser { get { return _parser; } }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pbr::MessageDescriptor Descriptor {
+ get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[16]; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ pbr::MessageDescriptor pb::IMessage.Descriptor {
+ get { return Descriptor; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorStatusListDto() {
+ OnConstruction();
+ }
+
+ partial void OnConstruction();
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorStatusListDto(SecretMirrorStatusListDto other) : this() {
+ nodes_ = other.nodes_.Clone();
+ _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SecretMirrorStatusListDto Clone() {
+ return new SecretMirrorStatusListDto(this);
+ }
+
+ /// Field number for the "nodes" field.
+ public const int NodesFieldNumber = 1;
+ private static readonly pb::FieldCodec _repeated_nodes_codec
+ = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorNodeStatusDto.Parser);
+ private readonly pbc::RepeatedField nodes_ = new pbc::RepeatedField();
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public pbc::RepeatedField Nodes {
+ get { return nodes_; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override bool Equals(object other) {
+ return Equals(other as SecretMirrorStatusListDto);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool Equals(SecretMirrorStatusListDto other) {
+ if (ReferenceEquals(other, null)) {
+ return false;
+ }
+ if (ReferenceEquals(other, this)) {
+ return true;
+ }
+ if(!nodes_.Equals(other.nodes_)) return false;
+ return Equals(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override int GetHashCode() {
+ int hash = 1;
+ hash ^= nodes_.GetHashCode();
+ if (_unknownFields != null) {
+ hash ^= _unknownFields.GetHashCode();
+ }
+ return hash;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override string ToString() {
+ return pb::JsonFormatter.ToDiagnosticString(this);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void WriteTo(pb::CodedOutputStream output) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ output.WriteRawMessage(this);
+ #else
+ nodes_.WriteTo(output, _repeated_nodes_codec);
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(output);
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+ nodes_.WriteTo(ref output, _repeated_nodes_codec);
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(ref output);
+ }
+ }
+ #endif
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int CalculateSize() {
+ int size = 0;
+ size += nodes_.CalculateSize(_repeated_nodes_codec);
+ if (_unknownFields != null) {
+ size += _unknownFields.CalculateSize();
+ }
+ return size;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(SecretMirrorStatusListDto other) {
+ if (other == null) {
+ return;
+ }
+ nodes_.Add(other.nodes_);
+ _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(pb::CodedInputStream input) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ input.ReadRawMessage(this);
+ #else
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+ break;
+ case 10: {
+ nodes_.AddEntriesFrom(input, _repeated_nodes_codec);
+ break;
+ }
+ }
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+ break;
+ case 10: {
+ nodes_.AddEntriesFrom(ref input, _repeated_nodes_codec);
+ break;
+ }
+ }
+ }
+ }
+ #endif
+
+ }
+
///
/// Site -> Central: periodic site health report. Mirrors Commons SiteHealthReport.
/// Additive-only evolution: field numbers are never reused.
@@ -4222,7 +4934,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pbr::MessageDescriptor Descriptor {
- get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[14]; }
+ get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[17]; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
@@ -4274,6 +4986,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
detachedScriptThreads_ = other.detachedScriptThreads_;
scriptRunShedCount_ = other.scriptRunShedCount_;
siteStreamAlarmDropCount_ = other.siteStreamAlarmDropCount_;
+ secretMirrorStatuses_ = other.secretMirrorStatuses_ != null ? other.secretMirrorStatuses_.Clone() : null;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -4721,6 +5434,23 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
}
+ /// Field number for the "secret_mirror_statuses" field.
+ public const int SecretMirrorStatusesFieldNumber = 33;
+ private global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto secretMirrorStatuses_;
+ ///
+ /// Review finding M1: every site node's deployed-secrets mirror status (the standby's
+ /// included — it is read from a LocalDb-replicated table on the active node).
+ /// Absent means null ("no data": central self-report, pre-M1 site, or not read yet).
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto SecretMirrorStatuses {
+ get { return secretMirrorStatuses_; }
+ set {
+ secretMirrorStatuses_ = value;
+ }
+ }
+
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -4768,6 +5498,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (DetachedScriptThreads != other.DetachedScriptThreads) return false;
if (ScriptRunShedCount != other.ScriptRunShedCount) return false;
if (SiteStreamAlarmDropCount != other.SiteStreamAlarmDropCount) return false;
+ if (!object.Equals(SecretMirrorStatuses, other.SecretMirrorStatuses)) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -4807,6 +5538,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (DetachedScriptThreads != 0) hash ^= DetachedScriptThreads.GetHashCode();
if (ScriptRunShedCount != 0) hash ^= ScriptRunShedCount.GetHashCode();
if (SiteStreamAlarmDropCount != 0L) hash ^= SiteStreamAlarmDropCount.GetHashCode();
+ if (secretMirrorStatuses_ != null) hash ^= SecretMirrorStatuses.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -4939,6 +5671,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(128, 2);
output.WriteInt64(SiteStreamAlarmDropCount);
}
+ if (secretMirrorStatuses_ != null) {
+ output.WriteRawTag(138, 2);
+ output.WriteMessage(SecretMirrorStatuses);
+ }
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -5063,6 +5799,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(128, 2);
output.WriteInt64(SiteStreamAlarmDropCount);
}
+ if (secretMirrorStatuses_ != null) {
+ output.WriteRawTag(138, 2);
+ output.WriteMessage(SecretMirrorStatuses);
+ }
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -5163,6 +5903,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (SiteStreamAlarmDropCount != 0L) {
size += 2 + pb::CodedOutputStream.ComputeInt64Size(SiteStreamAlarmDropCount);
}
+ if (secretMirrorStatuses_ != null) {
+ size += 2 + pb::CodedOutputStream.ComputeMessageSize(SecretMirrorStatuses);
+ }
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -5290,6 +6033,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (other.SiteStreamAlarmDropCount != 0L) {
SiteStreamAlarmDropCount = other.SiteStreamAlarmDropCount;
}
+ if (other.secretMirrorStatuses_ != null) {
+ if (secretMirrorStatuses_ == null) {
+ SecretMirrorStatuses = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto();
+ }
+ SecretMirrorStatuses.MergeFrom(other.SecretMirrorStatuses);
+ }
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -5467,6 +6216,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
SiteStreamAlarmDropCount = input.ReadInt64();
break;
}
+ case 266: {
+ if (secretMirrorStatuses_ == null) {
+ SecretMirrorStatuses = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto();
+ }
+ input.ReadMessage(SecretMirrorStatuses);
+ break;
+ }
}
}
#endif
@@ -5644,6 +6400,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
SiteStreamAlarmDropCount = input.ReadInt64();
break;
}
+ case 266: {
+ if (secretMirrorStatuses_ == null) {
+ SecretMirrorStatuses = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SecretMirrorStatusListDto();
+ }
+ input.ReadMessage(SecretMirrorStatuses);
+ break;
+ }
}
}
}
@@ -5669,7 +6432,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pbr::MessageDescriptor Descriptor {
- get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[15]; }
+ get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[18]; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
@@ -5984,7 +6747,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pbr::MessageDescriptor Descriptor {
- get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[16]; }
+ get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[19]; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs
index 0ffc5fd2..cf977609 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs
@@ -559,6 +559,32 @@ public static class CentralControlDtoMapper
dto.SiteAuditBacklog = snapshot;
}
+ // Review finding M1: wrapper presence keeps null ("no data") distinct from empty.
+ if (msg.SecretMirrorStatuses is { } mirrorStatuses)
+ {
+ var wrapper = new SecretMirrorStatusListDto();
+ foreach (var node in mirrorStatuses)
+ {
+ var nodeDto = new SecretMirrorNodeStatusDto
+ {
+ NodeName = node.NodeName,
+ CheckedAtUtc = Timestamp.FromDateTimeOffset(node.CheckedAtUtc),
+ };
+ foreach (var failure in node.Failures)
+ {
+ nodeDto.Failures.Add(new SecretMirrorFailureDto
+ {
+ SecretName = failure.SecretName,
+ Message = failure.Message,
+ });
+ }
+
+ wrapper.Nodes.Add(nodeDto);
+ }
+
+ dto.SecretMirrorStatuses = wrapper;
+ }
+
return dto;
}
@@ -620,6 +646,19 @@ public static class CentralControlDtoMapper
OnDiskBytes: snapshot.OnDiskBytes);
}
+ List? mirrorStatuses = null;
+ if (dto.SecretMirrorStatuses is { } mirrorWrapper)
+ {
+ mirrorStatuses = new List(mirrorWrapper.Nodes.Count);
+ foreach (var node in mirrorWrapper.Nodes)
+ {
+ mirrorStatuses.Add(new SecretMirrorNodeStatus(
+ node.NodeName,
+ node.CheckedAtUtc?.ToDateTimeOffset() ?? DateTimeOffset.MinValue,
+ node.Failures.Select(f => new SecretMirrorFailure(f.SecretName, f.Message)).ToList()));
+ }
+ }
+
return new SiteHealthReport(
SiteId: dto.SiteId,
SequenceNumber: dto.SequenceNumber,
@@ -657,6 +696,7 @@ public static class CentralControlDtoMapper
DetachedScriptThreads = dto.DetachedScriptThreads,
ScriptRunShedCount = dto.ScriptRunShedCount,
SiteStreamAlarmDropCount = dto.SiteStreamAlarmDropCount,
+ SecretMirrorStatuses = mirrorStatuses,
};
}
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 29014532..e092b3f9 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto
@@ -193,6 +193,25 @@ message NodeStatusListDto {
repeated NodeStatusDto nodes = 1;
}
+// Review finding M1: one site node's deployed-secrets mirror failures. Names and
+// human-readable messages only — never a secret value or ciphertext.
+message SecretMirrorFailureDto {
+ string secret_name = 1;
+ string message = 2;
+}
+
+message SecretMirrorNodeStatusDto {
+ string node_name = 1;
+ google.protobuf.Timestamp checked_at_utc = 2;
+ repeated SecretMirrorFailureDto failures = 3;
+}
+
+// Presence wrapper (same reason as NodeStatusListDto): absent = null ("no data"),
+// present-but-empty = the feature is wired and no node has recorded a status yet.
+message SecretMirrorStatusListDto {
+ repeated SecretMirrorNodeStatusDto nodes = 1;
+}
+
// Site -> Central: periodic site health report. Mirrors Commons SiteHealthReport.
// Additive-only evolution: field numbers are never reused.
message SiteHealthReportDto {
@@ -245,6 +264,10 @@ message SiteHealthReportDto {
// dedicated alarm publish queue because it was at capacity. Point-in-time
// (not reset on collect), like site_event_log_write_failures.
int64 site_stream_alarm_drop_count = 32;
+ // Review finding M1: every site node's deployed-secrets mirror status (the standby's
+ // included — it is read from a LocalDb-replicated table on the active node).
+ // Absent means null ("no data": central self-report, pre-M1 site, or not read yet).
+ SecretMirrorStatusListDto secret_mirror_statuses = 33;
}
// Central -> Site: health report ack, so a lost report is observable.
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs
index 0381f923..a1bdac40 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs
@@ -198,6 +198,19 @@ public interface ISiteHealthCollector
// Default no-op so test fakes do not need to be updated.
}
+ ///
+ /// Replace the latest per-node deployed-secrets mirror statuses (review finding M1) used
+ /// by the SecretMirrorStatuses field of the next call.
+ /// Refreshed periodically by the site-side SecretMirrorStatusReporter from the
+ /// LocalDb-replicated secret_mirror_status table, so the ACTIVE node's report
+ /// carries the STANDBY node's mirror failures too. Point-in-time: NOT reset on collect.
+ ///
+ /// Every node's row, or for "no data".
+ void SetSecretMirrorStatuses(IReadOnlyList? statuses)
+ {
+ // Default no-op so test fakes do not need to be updated.
+ }
+
///
/// Replace the latest script-execution-scheduler gauges (queue depth, busy
/// thread count, and age in seconds of the oldest in-flight script) used by
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs
index 200a4e8a..da08d5ac 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs
@@ -32,6 +32,10 @@ public class SiteHealthCollector : ISiteHealthCollector
// -1 = reconciler SKIPPED (no IDeploymentConfigFetcher)
// Null must stay distinct from -1: "we don't know" is not "self-heal is missing".
private volatile int _startupReconcilerActive;
+ // Review finding M1: every site node's deployed-secrets mirror status, refreshed by the
+ // SiteRuntime SecretMirrorStatusReporter. Null = the reporter has not run (or its read
+ // failed before it ever succeeded) — "no data", distinct from an empty list.
+ private volatile IReadOnlyList? _secretMirrorStatuses;
private readonly ConcurrentDictionary _connectionStatuses = new();
private readonly ConcurrentDictionary _tagResolutionCounts = new();
private readonly ConcurrentDictionary _connectionEndpoints = new();
@@ -130,6 +134,12 @@ public class SiteHealthCollector : ISiteHealthCollector
_startupReconcilerActive = active ? 1 : -1;
}
+ ///
+ public void SetSecretMirrorStatuses(IReadOnlyList? statuses)
+ {
+ _secretMirrorStatuses = statuses?.ToList();
+ }
+
///
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health)
{
@@ -333,7 +343,9 @@ public class SiteHealthCollector : ISiteHealthCollector
SiteStreamAlarmDropCount = Interlocked.Read(ref _siteStreamAlarmDropCount),
// Tri-state: 0 stays null ("not reported"), which must never collapse into
// false ("self-heal is missing"). Register row 73.
- StartupReconcilerActive = startupReconciler == 0 ? null : startupReconciler > 0
+ StartupReconcilerActive = startupReconciler == 0 ? null : startupReconciler > 0,
+ // M1: point-in-time, not drained; null stays null ("no data").
+ SecretMirrorStatuses = _secretMirrorStatuses
};
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs
index 85231b19..6d27556d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs
@@ -48,7 +48,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host;
public static class SiteLocalDbSetup
{
///
- /// The eleven tables that participate in replication — registered when this node has a peer,
+ /// The twelve tables that participate in replication — registered when this node has a peer,
/// and deregistered (stale triggers dropped) when it does not.
///
///
@@ -69,6 +69,13 @@ public static class SiteLocalDbSetup
/// deployed_secrets row, and replicating the ledger would make one node's progress
/// masquerade as the other's.
///
+ ///
+ /// secret_mirror_status is the twelfth entry (review finding M1). It is per-node
+ /// DATA — one row per node, keyed by node_name, each written only by its own node —
+ /// but it is replicated precisely so the active node can read the standby's row and put
+ /// it on the site health report (only the active node sends one). Explicit primary key,
+ /// no FK, no BLOB columns.
+ ///
///
internal static readonly string[] ReplicatedTables =
[
@@ -83,6 +90,7 @@ public static class SiteLocalDbSetup
"data_connection_definitions",
"native_alarm_state",
"deployed_secrets",
+ "secret_mirror_status",
];
///
@@ -140,8 +148,9 @@ public static class SiteLocalDbSetup
// ELEVENTH entry to ReplicatedTables on an already-replicating site streams a
// snapshot of only that one table, where through 0.2.x the flag was a single
// per-database bit and the same edit would have re-streamed all eleven in full, in
- // both directions. Nothing about the first boot changes: enabling replication on a
- // site that has been running without it seeds all eleven at once, the flagged set is
+ // both directions (the same holds for secret_mirror_status, the twelfth, added by
+ // review finding M1). Nothing about the first boot changes: enabling replication on a
+ // site that has been running without it seeds all twelve at once, the flagged set is
// therefore every registered table, and the library sends that as an ordinary full
// snapshot. A hypothetical "register the whole list in one call" API would silently
// give the coarse behaviour back.
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs
index 66532b78..c4793233 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs
@@ -3,8 +3,8 @@ using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
///
-/// DDL for the site's eleven configuration tables (nine original plus
-/// deployed_secrets and mirrored_secrets), extracted from
+/// DDL for the site's twelve configuration tables (nine original plus
+/// deployed_secrets, mirrored_secrets and secret_mirror_status), extracted from
/// so it can be applied by whoever owns the database
/// file.
///
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
public static class SiteStorageSchema
{
///
- /// Creates the eleven site configuration tables when absent, and additively upgrades
+ /// Creates the twelve site configuration tables when absent, and additively upgrades
/// tables created by an older build. Idempotent — safe to run on every startup.
///
/// An open connection to the database that should hold the tables.
@@ -136,6 +136,20 @@ public static class SiteStorageSchema
revision INTEGER NOT NULL,
mirrored_at TEXT NOT NULL
);
+
+ -- Per-node deployed-secrets mirror status (review finding M1): one row per site
+ -- node, keyed by node name, holding that node's CURRENT mirror failures as a JSON
+ -- array of {SecretName, Message} (names and messages only — never values or
+ -- ciphertext). Unlike mirrored_secrets this IS replicated
+ -- (SiteLocalDbSetup.ReplicatedTables): each node writes only its own row, and the
+ -- replication is exactly what lets the ACTIVE node's health report carry the
+ -- STANDBY node's failures to central. Written only when a node's failure set
+ -- changes (plus once per start-up), never per mirror tick. No foreign keys.
+ CREATE TABLE IF NOT EXISTS secret_mirror_status (
+ node_name TEXT PRIMARY KEY,
+ failures_json TEXT NOT NULL,
+ checked_at TEXT NOT NULL
+ );
";
command.ExecuteNonQuery();
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
index 880c0cbf..9ae4e132 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
@@ -3,6 +3,7 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
@@ -924,6 +925,87 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
+ // ── Secret mirror status (review finding M1 — LocalDb-replicated, one row per node) ──
+
+ ///
+ /// Records 's current deployed-secrets mirror failures in the
+ /// replicated secret_mirror_status table (UPSERT on the node name). An empty
+ /// list records a healthy mirror. Every call is a replicated
+ /// write, so callers write only when the failure set changes.
+ ///
+ /// This node's name — the row key.
+ /// The node's current failures (names and messages only, never values).
+ /// When the recorded failure set was observed.
+ /// A task that completes when the row has been stored.
+ public async Task SetSecretMirrorStatusAsync(
+ string nodeName, IReadOnlyList failures, DateTimeOffset checkedAtUtc)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(nodeName);
+ ArgumentNullException.ThrowIfNull(failures);
+
+ await using var connection = OpenConnection();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = @"
+ INSERT INTO secret_mirror_status (node_name, failures_json, checked_at)
+ VALUES (@nodeName, @failures, @checkedAt)
+ ON CONFLICT(node_name) DO UPDATE SET
+ failures_json = excluded.failures_json,
+ checked_at = excluded.checked_at";
+
+ command.Parameters.AddWithValue("@nodeName", nodeName);
+ command.Parameters.AddWithValue("@failures", JsonSerializer.Serialize(failures));
+ command.Parameters.AddWithValue("@checkedAt", checkedAtUtc.ToUniversalTime().ToString("O"));
+
+ await command.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Returns every node's row from secret_mirror_status, ordered by node name. A row
+ /// whose failures_json cannot be parsed is returned with a single synthetic failure
+ /// saying so, rather than failing the whole read or silently reading as healthy.
+ ///
+ /// A task that resolves to every node's recorded mirror status.
+ public async Task> GetSecretMirrorStatusesAsync()
+ {
+ await using var connection = OpenConnection();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText =
+ "SELECT node_name, failures_json, checked_at FROM secret_mirror_status ORDER BY node_name";
+
+ var results = new List();
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var nodeName = reader.GetString(0);
+ IReadOnlyList failures;
+ try
+ {
+ failures = JsonSerializer.Deserialize>(reader.GetString(1))
+ ?? [];
+ }
+ catch (Exception ex) when (ex is JsonException or NotSupportedException)
+ {
+ failures =
+ [
+ new SecretMirrorFailure(
+ "(unreadable)",
+ $"Mirror status row for node '{nodeName}' could not be parsed ({ex.GetType().Name})"),
+ ];
+ }
+
+ var checkedAt = DateTimeOffset.TryParse(
+ reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)
+ ? parsed
+ : DateTimeOffset.MinValue;
+
+ results.Add(new SecretMirrorNodeStatus(nodeName, checkedAt, failures));
+ }
+
+ return results;
+ }
+
// ── Shared Script CRUD ──
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs
index 993d1565..b07280df 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs
@@ -1,7 +1,9 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -153,6 +155,18 @@ public sealed record MirrorResult(
/// so a LATER failure — even one with an identical fingerprint — logs at Error again. This state
/// is in-memory only (per node process), which matches the ledger's own node-local scope.
///
+///
+/// Status row for central (review finding M1). A failure on the STANDBY node used to be
+/// visible only in that node's own log — site health reports are sent by the active node only.
+/// After each pass this mirror records its node's current failure set (secret name + message,
+/// never a value) in the LocalDb-replicated secret_mirror_status table, keyed by node
+/// name; SecretMirrorStatusReporter on the active node reads every node's row onto the
+/// health report. Because every write replicates via CDC, the row is written only when the
+/// failure set (compared as sorted name+message pairs) differs from what this process last
+/// wrote — plus once on the first pass after start-up, so a restart refreshes a stale row. The
+/// write is best-effort: a failure logs a Warning (repeats at Debug) and is retried on the next
+/// pass, and never affects the pass's own .
+///
///
public sealed class DeployedSecretsMirror : BackgroundService
{
@@ -165,6 +179,17 @@ public sealed class DeployedSecretsMirror : BackgroundService
private readonly IOptions _options;
private readonly ILogger _logger;
private readonly TimeProvider _time;
+ private readonly string _nodeName;
+
+ ///
+ /// The failure set (sorted name+message pairs) this process last wrote successfully to
+ /// secret_mirror_status, or before the first successful write
+ /// — which is what forces the once-per-start-up write (review finding M1).
+ ///
+ private IReadOnlyList? _lastWrittenStatus;
+
+ /// Whether a failed status write has already been logged at Warning (de-spam).
+ private bool _statusWriteFailureLogged;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
@@ -199,6 +224,11 @@ public sealed class DeployedSecretsMirror : BackgroundService
/// Supplies .
/// Logger instance. Never logs secret values — names, revisions, and KEK ids only.
/// Clock driving the reconcile loop's and the tombstone-recovery stamp.
+ ///
+ /// Supplies this node's name (node-a/node-b), the key of its
+ /// secret_mirror_status row. Optional: when absent or unconfigured, the machine name
+ /// is used instead.
+ ///
public DeployedSecretsMirror(
SiteStorageService storage,
ISecretStore store,
@@ -206,7 +236,8 @@ public sealed class DeployedSecretsMirror : BackgroundService
ISecretCacheInvalidator? invalidator,
IOptions options,
ILogger logger,
- TimeProvider time)
+ TimeProvider time,
+ INodeIdentityProvider? nodeIdentity = null)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_store = store ?? throw new ArgumentNullException(nameof(store));
@@ -215,6 +246,7 @@ public sealed class DeployedSecretsMirror : BackgroundService
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_time = time ?? throw new ArgumentNullException(nameof(time));
+ _nodeName = nodeIdentity?.NodeName ?? Environment.MachineName;
}
///
@@ -314,6 +346,8 @@ public sealed class DeployedSecretsMirror : BackgroundService
rows.Count(r => !r.IsRemoved) + unreadable.Count(u => !u.IsRemoved), failed.Count);
}
+ await RecordStatusIfChangedAsync(failedNames, failed).ConfigureAwait(false);
+
return result;
}
finally
@@ -322,6 +356,42 @@ public sealed class DeployedSecretsMirror : BackgroundService
}
}
+ ///
+ /// Writes this node's secret_mirror_status row when the pass's failure set differs
+ /// from the last one this process wrote (or nothing has been written yet since start-up).
+ /// Best-effort: never throws, so the pass result is unaffected by a status-write fault.
+ /// See the class remarks (review finding M1).
+ ///
+ private async Task RecordStatusIfChangedAsync(IReadOnlyList failedNames, IReadOnlyList failed)
+ {
+ var current = failedNames
+ .Select((name, i) => new SecretMirrorFailure(name, failed[i]))
+ .OrderBy(f => f.SecretName, StringComparer.Ordinal)
+ .ThenBy(f => f.Message, StringComparer.Ordinal)
+ .ToList();
+
+ if (_lastWrittenStatus is not null && _lastWrittenStatus.SequenceEqual(current))
+ {
+ return;
+ }
+
+ try
+ {
+ await _storage.SetSecretMirrorStatusAsync(_nodeName, current, _time.GetUtcNow()).ConfigureAwait(false);
+ _lastWrittenStatus = current;
+ _statusWriteFailureLogged = false;
+ }
+ catch (Exception ex)
+ {
+ _logger.Log(
+ _statusWriteFailureLogged ? LogLevel.Debug : LogLevel.Warning,
+ ex,
+ "Failed to record the deployed secrets mirror status for node {NodeName}; retrying next pass",
+ _nodeName);
+ _statusWriteFailureLogged = true;
+ }
+ }
+
///
/// Handles a tombstoned deployed_secrets row: deletes it from this node's store, but
/// only if this node had previously ledgered it as mirrored — an out-of-band, same-named
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/SecretMirrorStatusReporter.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/SecretMirrorStatusReporter.cs
new file mode 100644
index 00000000..85abb4d8
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/SecretMirrorStatusReporter.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets;
+
+///
+/// Review finding M1: site-side hosted service that periodically reads EVERY node's row from
+/// the LocalDb-replicated secret_mirror_status table and pushes the list into
+/// , so the next health report
+/// carries each site node's deployed-secrets mirror failures — the standby's included.
+///
+///
+///
+/// Runs on every site node (the read is a cheap local SELECT), but only the active node's
+/// report is ever sent (HealthReportSender), which is exactly why the status lives in a
+/// REPLICATED table: the standby writes its row, CDC carries it to the active node, and this
+/// reporter on the active node forwards it. A site pair WITHOUT LocalDb replication reports
+/// only the active node's row — the same prerequisite the deployed secrets themselves have.
+///
+///
+/// Lives in SiteRuntime (which already references HealthMonitoring and owns
+/// ), following SiteStreamAlarmDropReporter: immediate
+/// first probe, fixed cadence, exceptions logged and swallowed. A failed read leaves the
+/// previous snapshot on the collector rather than clearing it.
+///
+///
+public sealed class SecretMirrorStatusReporter : BackgroundService
+{
+ /// Default poll cadence (30 s), matching the site health report interval.
+ internal static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(30);
+
+ private readonly ISiteHealthCollector _collector;
+ private readonly SiteStorageService _storage;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _pollInterval;
+
+ /// Initializes a new instance of .
+ /// The site health collector receiving the statuses.
+ /// Site storage exposing secret_mirror_status.
+ /// Logger instance.
+ /// Poll interval override; defaults to (30 s).
+ public SecretMirrorStatusReporter(
+ ISiteHealthCollector collector,
+ SiteStorageService storage,
+ ILogger logger,
+ TimeSpan? pollInterval = null)
+ {
+ _collector = collector ?? throw new ArgumentNullException(nameof(collector));
+ _storage = storage ?? throw new ArgumentNullException(nameof(storage));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _pollInterval = pollInterval ?? DefaultPollInterval;
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await ProbeAsync().ConfigureAwait(false);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_pollInterval, stoppingToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ await ProbeAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Reads every node's mirror status and pushes it onto the collector; never throws.
+ /// Public so a test can drive one deterministic probe.
+ ///
+ /// A task that completes when the probe has finished.
+ public async Task ProbeAsync()
+ {
+ try
+ {
+ var statuses = await _storage.GetSecretMirrorStatusesAsync().ConfigureAwait(false);
+ _collector.SetSecretMirrorStatuses(statuses);
+ }
+ catch (Exception ex)
+ {
+ // Catch-all is deliberate: health reporting must never take the node down, and
+ // the loop must survive so the next tick retries.
+ _logger.LogWarning(ex, "SecretMirrorStatusReporter probe failed; next tick will retry.");
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
index bbb8b139..a8bffc1b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
@@ -140,6 +140,13 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
services.AddHostedService(sp => sp.GetRequiredService());
+ // Review finding M1: forward every node's replicated secret_mirror_status row onto the
+ // site health report, so the ACTIVE node's report carries the STANDBY's mirror failures.
+ services.AddHostedService(sp => new SecretMirrorStatusReporter(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>()));
+
return services;
}
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 d90fe937..f141eb2c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs
@@ -559,6 +559,70 @@ public class HealthPageTests : BunitContext
StartupReconcilerActive = active,
};
+ // ----- Review finding M1: per-node deployed-secrets mirror failures ------------------
+ //
+ // The standby's failures ride the active node's report; the card must name the node,
+ // the secret and the message. Null ("no data") and all-healthy nodes render nothing.
+
+ [Fact]
+ public void Renders_SecretMirrorFailures_LabelledByNode()
+ {
+ SeedStates(new SiteHealthState
+ {
+ SiteId = "site-a",
+ IsOnline = true,
+ LastHeartbeatAt = DateTimeOffset.UtcNow,
+ LatestReport = ReportWithReconciler("site-a", active: true) with
+ {
+ SecretMirrorStatuses =
+ [
+ new SecretMirrorNodeStatus("node-a", DateTimeOffset.UtcNow, []),
+ new SecretMirrorNodeStatus(
+ "node-b",
+ DateTimeOffset.UtcNow,
+ [new SecretMirrorFailure("OPC-PWD", "did not decrypt on this node")]),
+ ],
+ },
+ });
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Contains("data-test=\"secret-mirror-badge\"", cut.Markup);
+ var panel = cut.Find("[data-test=secret-mirror-failures]");
+ Assert.Contains("node-b", panel.TextContent);
+ Assert.Contains("OPC-PWD", panel.TextContent);
+ Assert.Contains("did not decrypt on this node", panel.TextContent);
+ Assert.DoesNotContain("node-a", panel.TextContent);
+ });
+ }
+
+ [Theory]
+ [InlineData(false)] // null = "no data" (central self-report, pre-M1 site)
+ [InlineData(true)] // every node healthy
+ public void DoesNotRender_SecretMirrorWarning_WhenNoNodeIsFailing(bool reported)
+ {
+ SeedStates(new SiteHealthState
+ {
+ SiteId = "site-a",
+ IsOnline = true,
+ LastHeartbeatAt = DateTimeOffset.UtcNow,
+ LatestReport = ReportWithReconciler("site-a", active: true) with
+ {
+ SecretMirrorStatuses = reported
+ ? [new SecretMirrorNodeStatus("node-a", DateTimeOffset.UtcNow, [])]
+ : null,
+ },
+ });
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
+ Assert.DoesNotContain("data-test=\"secret-mirror-badge\"", cut.Markup);
+ Assert.DoesNotContain("data-test=\"secret-mirror-failures\"", cut.Markup);
+ }
+
[Fact]
public void Renders_OfflineSince_ForOfflineSiteWithStatusChange()
{
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 31d83c0e..cdabecb9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs
@@ -369,6 +369,7 @@ public class CentralControlDtoMapperTests
Assert.Null(roundTripped.LocalDbReplicationConnected);
Assert.Null(roundTripped.LocalDbOplogBacklog);
Assert.Null(roundTripped.StartupReconcilerActive);
+ Assert.Null(roundTripped.SecretMirrorStatuses);
}
[Fact]
@@ -384,10 +385,14 @@ public class CentralControlDtoMapperTests
DataConnectionEndpoints = new Dictionary(),
DataConnectionTagQuality = new Dictionary(),
ClusterNodes = [],
+ SecretMirrorStatuses = [],
};
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
+ Assert.NotNull(roundTripped.SecretMirrorStatuses);
+ Assert.Empty(roundTripped.SecretMirrorStatuses);
+
Assert.NotNull(roundTripped.DataConnectionEndpoints);
Assert.Empty(roundTripped.DataConnectionEndpoints);
Assert.NotNull(roundTripped.DataConnectionTagQuality);
@@ -559,6 +564,18 @@ public class CentralControlDtoMapperTests
DetachedScriptThreads = 2,
ScriptRunShedCount = 13,
SiteStreamAlarmDropCount = 41L,
+ // Review finding M1: every node's secret mirror status, standby included.
+ SecretMirrorStatuses =
+ [
+ new SecretMirrorNodeStatus("node-a", SampleInstant, []),
+ new SecretMirrorNodeStatus(
+ "node-b",
+ SampleInstant.AddMinutes(-3),
+ [
+ new SecretMirrorFailure("OPC-PWD", "did not decrypt on this node"),
+ new SecretMirrorFailure("MX-KEY", "could not be confirmed applied"),
+ ]),
+ ],
};
///
@@ -611,6 +628,17 @@ public class CentralControlDtoMapperTests
Assert.Equal(expected.DetachedScriptThreads, actual.DetachedScriptThreads);
Assert.Equal(expected.ScriptRunShedCount, actual.ScriptRunShedCount);
Assert.Equal(expected.SiteStreamAlarmDropCount, actual.SiteStreamAlarmDropCount);
+ Assert.Equal(expected.SecretMirrorStatuses is null, actual.SecretMirrorStatuses is null);
+ if (expected.SecretMirrorStatuses is not null)
+ {
+ Assert.Equal(expected.SecretMirrorStatuses.Count, actual.SecretMirrorStatuses!.Count);
+ for (var i = 0; i < expected.SecretMirrorStatuses.Count; i++)
+ {
+ Assert.Equal(expected.SecretMirrorStatuses[i].NodeName, actual.SecretMirrorStatuses[i].NodeName);
+ Assert.Equal(expected.SecretMirrorStatuses[i].CheckedAtUtc, actual.SecretMirrorStatuses[i].CheckedAtUtc);
+ Assert.Equal(expected.SecretMirrorStatuses[i].Failures, actual.SecretMirrorStatuses[i].Failures);
+ }
+ }
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs
new file mode 100644
index 00000000..0f7ac2ea
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs
@@ -0,0 +1,45 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
+
+namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests;
+
+///
+/// Review finding M1 — SiteHealthReport.SecretMirrorStatuses carries every site node's
+/// deployed-secrets mirror failures (the standby's included) on the active node's report.
+///
+public class SecretMirrorStatusHealthFieldTests
+{
+ [Fact]
+ public void CollectReport_NeverTold_LeavesTheFieldNull()
+ {
+ Assert.Null(new SiteHealthCollector().CollectReport("TestSite").SecretMirrorStatuses);
+ }
+
+ [Fact]
+ public void CollectReport_CarriesTheLatestSnapshot_AndDoesNotDrainIt()
+ {
+ var collector = new SiteHealthCollector();
+ collector.SetSecretMirrorStatuses(
+ [
+ new SecretMirrorNodeStatus("node-a", DateTimeOffset.UtcNow, []),
+ new SecretMirrorNodeStatus(
+ "node-b", DateTimeOffset.UtcNow, [new SecretMirrorFailure("S1", "did not decrypt")]),
+ ]);
+
+ var first = collector.CollectReport("TestSite").SecretMirrorStatuses!;
+ var second = collector.CollectReport("TestSite").SecretMirrorStatuses!;
+
+ Assert.Equal(["node-a", "node-b"], first.Select(s => s.NodeName));
+ Assert.Equal(2, second.Count); // point-in-time, not interval-drained
+ Assert.Equal("S1", Assert.Single(first[1].Failures).SecretName);
+ }
+
+ [Fact]
+ public void SetNull_ResetsToNoData()
+ {
+ var collector = new SiteHealthCollector();
+ collector.SetSecretMirrorStatuses([]);
+ collector.SetSecretMirrorStatuses(null);
+
+ Assert.Null(collector.CollectReport("TestSite").SecretMirrorStatuses);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs
index 36fd3007..a2469924 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs
@@ -212,7 +212,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
// ---- helpers ----------------------------------------------------------------------
///
- /// The eleven replicated tables, plus the two deliberately-unregistered central-only
+ /// The twelve replicated tables, plus the two deliberately-unregistered central-only
/// notification tables, plus the node-local (also deliberately-unregistered)
/// mirrored_secrets ledger.
///
@@ -221,7 +221,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
"OperationTracking", "site_events", "sf_messages", "deployed_configurations",
"static_attribute_overrides", "shared_scripts", "external_systems",
"database_connections", "data_connection_definitions", "native_alarm_state",
- "deployed_secrets",
+ "deployed_secrets", "secret_mirror_status",
"notification_lists", "smtp_configurations", "mirrored_secrets",
];
@@ -230,7 +230,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
"OperationTracking", "site_events", "sf_messages", "deployed_configurations",
"static_attribute_overrides", "shared_scripts", "external_systems",
"database_connections", "data_connection_definitions", "native_alarm_state",
- "deployed_secrets",
+ "deployed_secrets", "secret_mirror_status",
];
private static void AssertCaptureTriggersCoverTheReplicatedTables(ILocalDb db)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs
index 120e30b1..1ecc0784 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs
@@ -15,8 +15,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// RegisterReplicated time, which on a site node is a BOOT failure of the replicating
/// pair. This test moves that discovery from the first replicated boot to the build: it walks
/// the real site schema (every table SiteLocalDbSetup.OnReady creates) and asserts none
-/// of the eleven (incl. deployed_secrets,
-/// added for site-secrets artifact deploy) has such an inbound FK.
+/// of the twelve (incl. deployed_secrets,
+/// added for site-secrets artifact deploy, and secret_mirror_status, added by review
+/// finding M1) has such an inbound FK.
///
///
/// The schema is built with an UNREPLICATED config on purpose: a replicated one would make
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs
index 74e78ae4..f5a473cd 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs
@@ -254,7 +254,7 @@ public class SiteLocalDbWiringTests : IDisposable
"deployed_configurations", "static_attribute_overrides", "shared_scripts",
"external_systems", "database_connections", "notification_lists",
"data_connection_definitions", "smtp_configurations", "native_alarm_state",
- "deployed_secrets", "mirrored_secrets",
+ "deployed_secrets", "mirrored_secrets", "secret_mirror_status",
})
{
Assert.Contains(table, tables);
@@ -262,9 +262,10 @@ public class SiteLocalDbWiringTests : IDisposable
}
[Fact]
- public void Site_LocalDb_RegistersExactlyTheElevenReplicatedTables()
+ public void Site_LocalDb_RegistersExactlyTheTwelveReplicatedTables()
{
- // The Phase 2 cutover, extended by site-secrets artifact deploy's deployed_secrets.
+ // The Phase 2 cutover, extended by site-secrets artifact deploy's deployed_secrets
+ // and review finding M1's per-node secret_mirror_status.
// This is an EXACTLY check rather than a Contains in both directions, and both
// directions are load-bearing.
//
@@ -285,8 +286,8 @@ public class SiteLocalDbWiringTests : IDisposable
// data_connection_definitions precedes database_connections.
"OperationTracking", "data_connection_definitions", "database_connections",
"deployed_configurations", "deployed_secrets", "external_systems",
- "native_alarm_state", "sf_messages", "shared_scripts", "site_events",
- "static_attribute_overrides",
+ "native_alarm_state", "secret_mirror_status", "sf_messages", "shared_scripts",
+ "site_events", "static_attribute_overrides",
],
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
}
@@ -334,6 +335,7 @@ public class SiteLocalDbWiringTests : IDisposable
["instance_unique_name", "source_canonical_name", "source_reference"],
db.ReplicatedTables["native_alarm_state"].PkColumns);
Assert.Equal(["name"], db.ReplicatedTables["deployed_secrets"].PkColumns);
+ Assert.Equal(["node_name"], db.ReplicatedTables["secret_mirror_status"].PkColumns);
}
private static HashSet TableNames(ILocalDb db)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs
index 79844668..29949c71 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.TestSupport;
@@ -303,6 +304,54 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("A", mirrored.Name);
}
+ // ── secret_mirror_status (review finding M1) ──
+
+ [Fact]
+ public async Task SecretMirrorStatus_RoundTrips_OneRowPerNode_OrderedByNodeName()
+ {
+ var at = new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero);
+ await _storage.SetSecretMirrorStatusAsync(
+ "node-b", [new SecretMirrorFailure("A", "did not decrypt")], at);
+ await _storage.SetSecretMirrorStatusAsync("node-a", [], at.AddMinutes(1));
+
+ var rows = await _storage.GetSecretMirrorStatusesAsync();
+
+ Assert.Equal(["node-a", "node-b"], rows.Select(r => r.NodeName));
+ Assert.Empty(rows[0].Failures);
+ Assert.Equal(at.AddMinutes(1), rows[0].CheckedAtUtc);
+ var failure = Assert.Single(rows[1].Failures);
+ Assert.Equal(new SecretMirrorFailure("A", "did not decrypt"), failure);
+ Assert.Equal(at, rows[1].CheckedAtUtc);
+ }
+
+ [Fact]
+ public async Task SecretMirrorStatus_Set_UpsertsTheNodesRow()
+ {
+ var at = new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero);
+ await _storage.SetSecretMirrorStatusAsync("node-a", [new SecretMirrorFailure("A", "x")], at);
+ await _storage.SetSecretMirrorStatusAsync("node-a", [], at.AddHours(1));
+
+ var row = Assert.Single(await _storage.GetSecretMirrorStatusesAsync());
+ Assert.Empty(row.Failures);
+ Assert.Equal(at.AddHours(1), row.CheckedAtUtc);
+ }
+
+ [Fact]
+ public async Task SecretMirrorStatus_UnparseableRow_ReadsAsAFailure_NotAsHealthy()
+ {
+ await using (var connection = _storage.CreateConnection())
+ await using (var insert = connection.CreateCommand())
+ {
+ insert.CommandText =
+ "INSERT INTO secret_mirror_status (node_name, failures_json, checked_at) VALUES ('node-b', '{not json', '2026-09-28T00:00:00.0000000+00:00')";
+ await insert.ExecuteNonQueryAsync();
+ }
+
+ var row = Assert.Single(await _storage.GetSecretMirrorStatusesAsync());
+ Assert.Equal("node-b", row.NodeName);
+ Assert.Single(row.Failures);
+ }
+
private static SecretArtifact Art(string name, long revision) => new(
Name: name,
ContentType: "application/octet-stream",
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs
index 4816743c..99c23447 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs
@@ -23,14 +23,14 @@ public class SiteStorageSchemaTests
SiteStorageSchema.Apply(connection);
SiteStorageSchema.Apply(connection); // second run must not throw
- // Every table is named explicitly on purpose — a loop asserting "11 tables"
+ // Every table is named explicitly on purpose — a loop asserting "12 tables"
// would still pass if one were renamed.
foreach (var table in new[]
{
"deployed_configurations", "static_attribute_overrides", "shared_scripts",
"external_systems", "database_connections", "notification_lists",
"data_connection_definitions", "smtp_configurations", "native_alarm_state",
- "deployed_secrets", "mirrored_secrets",
+ "deployed_secrets", "mirrored_secrets", "secret_mirror_status",
})
{
Assert.True(TableExists(connection, table), $"missing table: {table}");
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs
index f7f728a5..1b694249 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets;
@@ -655,6 +656,132 @@ public class DeployedSecretsMirrorTests : IDisposable
}
}
+ // ── Per-node status row for central (review finding M1) ──
+
+ [Fact]
+ public async Task Status_FirstPassAfterStartup_WritesThisNodesRow_EvenWhenHealthy()
+ {
+ var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero));
+ var (mirror, storage, _, _, _) = await CreateAsync(time: time, nodeIdentity: new FixedNodeIdentity("node-b"));
+
+ await mirror.ReconcileAsync(CancellationToken.None);
+
+ var status = Assert.Single(await storage.GetSecretMirrorStatusesAsync());
+ Assert.Equal("node-b", status.NodeName);
+ Assert.Empty(status.Failures);
+ Assert.Equal(time.Now, status.CheckedAtUtc);
+ }
+
+ [Fact]
+ public async Task Status_UnchangedFailureSet_IsNotRewrittenOnLaterPasses()
+ {
+ var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero));
+ var (mirror, storage, _, resolver, _) = await CreateAsync(time: time, nodeIdentity: new FixedNodeIdentity("node-b"));
+ resolver.ThrowFor("a");
+ await storage.UpsertDeployedSecretsAsync([Art("a", 1, kek: "kek-central")], "d1");
+
+ await mirror.ReconcileAsync(CancellationToken.None);
+ var firstWrite = Assert.Single(await storage.GetSecretMirrorStatusesAsync());
+
+ // Same failure on every later pass: the row is replicated, so it must not be rewritten.
+ time.Now = time.Now.AddMinutes(5);
+ await mirror.ReconcileAsync(CancellationToken.None);
+ await mirror.ReconcileAsync(CancellationToken.None);
+
+ var after = Assert.Single(await storage.GetSecretMirrorStatusesAsync());
+ Assert.Equal(firstWrite.CheckedAtUtc, after.CheckedAtUtc);
+ var failure = Assert.Single(after.Failures);
+ Assert.Equal("a", failure.SecretName);
+ Assert.Contains("kek-central", failure.Message);
+ }
+
+ [Fact]
+ public async Task Status_FailureSetChange_RewritesTheRow_AndClearsWhenHealed()
+ {
+ var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero));
+ var (mirror, storage, _, resolver, _) = await CreateAsync(time: time, nodeIdentity: new FixedNodeIdentity("node-a"));
+ await storage.UpsertDeployedSecretsAsync([Art("a", 1)], "d1");
+
+ await mirror.ReconcileAsync(CancellationToken.None);
+ Assert.Empty(Assert.Single(await storage.GetSecretMirrorStatusesAsync()).Failures);
+
+ // A decrypt failure appears -> row rewritten with the failure.
+ resolver.ThrowFor("a");
+ time.Now = time.Now.AddMinutes(1);
+ await mirror.ReconcileAsync(CancellationToken.None);
+ var failing = Assert.Single(await storage.GetSecretMirrorStatusesAsync());
+ Assert.Equal("a", Assert.Single(failing.Failures).SecretName);
+ Assert.Equal(time.Now, failing.CheckedAtUtc);
+
+ // Healed -> row rewritten empty.
+ resolver.ClearFailures();
+ time.Now = time.Now.AddMinutes(1);
+ await mirror.ReconcileAsync(CancellationToken.None);
+ var healed = Assert.Single(await storage.GetSecretMirrorStatusesAsync());
+ Assert.Empty(healed.Failures);
+ Assert.Equal(time.Now, healed.CheckedAtUtc);
+ }
+
+ [Fact]
+ public async Task Status_RowCarriesNamesAndMessagesOnly_NeverCiphertext()
+ {
+ var (mirror, storage, _, resolver, _) = await CreateAsync(nodeIdentity: new FixedNodeIdentity("node-a"));
+ resolver.ThrowFor("a");
+ await storage.UpsertDeployedSecretsAsync([Art("a", 1)], "d1");
+
+ await mirror.ReconcileAsync(CancellationToken.None);
+
+ await using var connection = storage.CreateConnection();
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT failures_json FROM secret_mirror_status WHERE node_name = 'node-a'";
+ var json = (string)(await command.ExecuteScalarAsync())!;
+ Assert.Contains("\"SecretName\":\"a\"", json);
+ Assert.DoesNotContain("Ciphertext", json);
+ Assert.DoesNotContain(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), json);
+ }
+
+ [Fact]
+ public async Task Status_WriteFailure_DoesNotBreakThePass_AndIsRetriedNextPass()
+ {
+ var logger = new CapturingLogger();
+ var (mirror, storage, store, _, _) = await CreateAsync(logger: logger, nodeIdentity: new FixedNodeIdentity("node-a"));
+ await storage.UpsertDeployedSecretsAsync([Art("a", 1)], "d1");
+
+ await using (var connection = storage.CreateConnection())
+ await using (var drop = connection.CreateCommand())
+ {
+ drop.CommandText = "DROP TABLE secret_mirror_status";
+ await drop.ExecuteNonQueryAsync();
+ }
+
+ var r = await mirror.ReconcileAsync(CancellationToken.None);
+
+ Assert.Equal(["a"], r.Applied);
+ Assert.True(store.Contains("a"));
+ Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("mirror status"));
+
+ // Table back (e.g. transient fault cleared): the unchanged failure set is still
+ // written, because the earlier write never landed.
+ await storage.InitializeAsync();
+ await mirror.ReconcileAsync(CancellationToken.None);
+ Assert.Equal("node-a", Assert.Single(await storage.GetSecretMirrorStatusesAsync()).NodeName);
+ }
+
+ [Fact]
+ public async Task Status_WithoutNodeIdentity_FallsBackToMachineName()
+ {
+ var (mirror, storage, _, _, _) = await CreateAsync();
+
+ await mirror.ReconcileAsync(CancellationToken.None);
+
+ Assert.Equal(Environment.MachineName, Assert.Single(await storage.GetSecretMirrorStatusesAsync()).NodeName);
+ }
+
+ private sealed class FixedNodeIdentity(string? name) : INodeIdentityProvider
+ {
+ public string? NodeName { get; } = name;
+ }
+
// ── Harness ──
private async Task<(
@@ -665,7 +792,8 @@ public class DeployedSecretsMirrorTests : IDisposable
FakeSecretCacheInvalidator Invalidator)> CreateAsync(
CapturingLogger? logger = null,
TimeSpan? interval = null,
- TimeProvider? time = null)
+ TimeProvider? time = null,
+ INodeIdentityProvider? nodeIdentity = null)
{
var storage = new SiteStorageService(_localDb.Db, NullLogger.Instance);
await storage.InitializeAsync();
@@ -685,7 +813,8 @@ public class DeployedSecretsMirrorTests : IDisposable
invalidator,
options,
(ILogger?)logger ?? NullLogger.Instance,
- time ?? TimeProvider.System);
+ time ?? TimeProvider.System,
+ nodeIdentity);
return (mirror, storage, store, resolver, invalidator);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs
new file mode 100644
index 00000000..42790b9c
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
+using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets;
+using ZB.MOM.WW.ScadaBridge.TestSupport;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets;
+
+///
+/// Review finding M1: forwards every node's
+/// replicated secret_mirror_status row onto the site health report via the collector.
+///
+public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
+{
+ private readonly TestLocalDb _localDb = TestLocalDb.CreateTemp("secret-mirror-status");
+ private SiteStorageService _storage = null!;
+
+ public async Task InitializeAsync()
+ {
+ _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance);
+ await _storage.InitializeAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ var path = _localDb.Path;
+ _localDb.Dispose();
+ TestLocalDb.DeleteFiles(path);
+ }
+
+ [Fact]
+ public async Task Probe_PushesEveryNodesRow_OntoTheNextReport()
+ {
+ var at = new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero);
+ await _storage.SetSecretMirrorStatusAsync("node-a", [], at);
+ await _storage.SetSecretMirrorStatusAsync(
+ "node-b", [new SecretMirrorFailure("OPC-PWD", "did not decrypt on this node")], at);
+ var collector = new SiteHealthCollector();
+ var reporter = new SecretMirrorStatusReporter(
+ collector, _storage, NullLogger.Instance);
+
+ await reporter.ProbeAsync();
+ var report = collector.CollectReport("site-a");
+
+ Assert.NotNull(report.SecretMirrorStatuses);
+ Assert.Equal(["node-a", "node-b"], report.SecretMirrorStatuses!.Select(s => s.NodeName));
+ Assert.Equal("OPC-PWD", Assert.Single(report.SecretMirrorStatuses[1].Failures).SecretName);
+ }
+
+ [Fact]
+ public void BeforeAnyProbe_TheReportFieldIsNull()
+ {
+ var report = new SiteHealthCollector().CollectReport("site-a");
+
+ Assert.Null(report.SecretMirrorStatuses);
+ }
+
+ [Fact]
+ public async Task Probe_NoRowsYet_PushesAnEmptyList_NotNull()
+ {
+ var collector = new SiteHealthCollector();
+ var reporter = new SecretMirrorStatusReporter(
+ collector, _storage, NullLogger.Instance);
+
+ await reporter.ProbeAsync();
+
+ Assert.Empty(collector.CollectReport("site-a").SecretMirrorStatuses!);
+ }
+
+ [Fact]
+ public async Task Probe_ReadFailure_KeepsThePreviousSnapshot_AndDoesNotThrow()
+ {
+ await _storage.SetSecretMirrorStatusAsync("node-a", [], DateTimeOffset.UtcNow);
+ var collector = new SiteHealthCollector();
+ var reporter = new SecretMirrorStatusReporter(
+ collector, _storage, NullLogger.Instance);
+ await reporter.ProbeAsync();
+
+ await using (var connection = _storage.CreateConnection())
+ await using (var drop = connection.CreateCommand())
+ {
+ drop.CommandText = "DROP TABLE secret_mirror_status";
+ await drop.ExecuteNonQueryAsync();
+ }
+
+ await reporter.ProbeAsync();
+
+ // Point-in-time: not drained on collect, and not cleared by a failed read.
+ Assert.Single(collector.CollectReport("site-a").SecretMirrorStatuses!);
+ Assert.Single(collector.CollectReport("site-a").SecretMirrorStatuses!);
+ }
+}