feat(grpc): central_control.proto + DTO mapper for the 7 site→central control RPCs (T1A.1)
Phase 1A of the ClusterClient→gRPC migration needs a wire contract for the seven messages SiteCommunicationActor forwards to /user/central-communication. This lands the contract and its mapper only — hosting (T1A.2) and the site-side transport seam (T1A.3) follow. `Protos/central_control.proto` (package scadabridge.centralcontrol.v1, service CentralControlService) declares SubmitNotification, QueryNotificationStatus, IngestAuditEvents, IngestCachedTelemetry, ReconcileSite, ReportSiteHealth and Heartbeat. Note the direction is the inverse of SiteStreamService: here the site dials and central serves. Decisions worth recording: - The two ingest RPCs IMPORT sitestream.proto and reuse AuditEventBatch / CachedTelemetryBatch / IngestAck rather than redeclaring them. The site telemetry actor already builds those messages, so a second copy would fork one wire contract into two kept in lockstep by hand. ForwardState / IngestedAtUtc stay off-wire exactly as they are today. - Heartbeat replies google.protobuf.Empty — it is fire-and-forget and must never surface a fault onto the heartbeat timer path. - The three NULLABLE SiteHealthReport collections travel inside single-field wrapper messages (ConnectionEndpointMapDto / TagQualityMapDto / NodeStatusListDto). proto3 cannot express presence on repeated/map fields, but null and empty genuinely differ here — SiteHealthCollector emits `ClusterNodes: _clusterNodes?.ToList()` and the central health surface reads null as "not reported", not as "reported empty". Same reasoning drives the BoolValue/Int64Value/DoubleValue wrappers on LocalDbReplicationConnected, LocalDbOplogBacklog and the two age gauges, whose docs are explicit that null is not zero/false. - ConnectionHealthEnum reserves 0 for UNSPECIFIED instead of mapping Connected onto it, and the decoder resolves anything unknown to ConnectionHealth.Error. An unrecognised connection state must not render as "healthy". - Guid? execution ids travel as "D" strings with empty meaning null; a malformed non-empty value throws rather than being laundered into "no correlation". - DateTimeOffset normalizes to a UTC instant (a protobuf Timestamp has no offset). Lossless in practice — every producer stamps UTC — and documented + asserted rather than left implicit. SiteCallDtoMapper gains a ToDto(SiteCall) overload. Its doc comment previously asserted such a method "would be dead code"; that held only while ClusterClient was the sole path from IngestCachedTelemetryCommand (which carries SiteCall, not SiteCallOperational) to central. Comment corrected alongside. Golden tests round-trip every message through a real protobuf encode/decode — DTO → proto → bytes → proto → DTO — with a fully-populated case and a null/empty/minimal case for every optional member. Verified to have teeth by mutation: dropping a scalar, collapsing an empty nullable collection to absent, and nulling a gauge each fail a test. Codegen stays CHECKED IN under CentralControlGrpc/ (protoc segfaults in the linux_arm64 Docker image); no active <Protobuf> item is committed. docker/regen-proto.sh is generalized to `regen-proto.sh [sitestream| centralcontrol|all]` — it now injects the ItemGroup rather than unwrapping a comment, so it no longer depends on there being exactly one Protobuf line, and it restores the csproj verbatim on every exit path.
This commit is contained in:
+75
-49
@@ -1,92 +1,118 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Regenerates the gRPC C# files from sitestream.proto.
|
# Regenerates the gRPC C# files from the Communication project's .proto files.
|
||||||
#
|
#
|
||||||
# Background: protoc (linux/arm64) segfaults inside our Docker build container
|
# Background: protoc (linux/arm64) segfaults inside our Docker build container
|
||||||
# (Grpc.Tools 2.71.0). As a workaround the generated Sitestream.cs +
|
# (Grpc.Tools 2.71.0). As a workaround the generated C# is checked into
|
||||||
# SitestreamGrpc.cs are checked into src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/
|
# src/ZB.MOM.WW.ScadaBridge.Communication/ — SiteStreamGrpc/ for sitestream.proto
|
||||||
# and the Protobuf ItemGroup in the .csproj is commented out — Docker just
|
# and CentralControlGrpc/ for central_control.proto — and the Protobuf ItemGroup
|
||||||
# compiles the checked-in C# files.
|
# in the .csproj is commented out, so Docker just compiles the checked-in files.
|
||||||
#
|
#
|
||||||
# Run this script ON YOUR DEV MACHINE whenever Protos/sitestream.proto changes:
|
# Run this script ON YOUR DEV MACHINE whenever a .proto changes:
|
||||||
#
|
#
|
||||||
# 1. Temporarily uncomments the Protobuf ItemGroup so Grpc.Tools runs.
|
# docker/regen-proto.sh [sitestream|centralcontrol|all] (default: all)
|
||||||
# 2. dotnet build (regen writes fresh files to obj/).
|
#
|
||||||
# 3. Copies the regenerated files back into SiteStreamGrpc/.
|
# 1. Injects a Protobuf ItemGroup for the selected proto(s) so Grpc.Tools runs.
|
||||||
# 4. Re-comments the Protobuf ItemGroup so Docker builds stay safe.
|
# 2. Deletes the stale checked-in C# so a failed regen is obvious.
|
||||||
|
# 3. dotnet build (regen writes fresh files to obj/).
|
||||||
|
# 4. Copies the regenerated files back into the source tree.
|
||||||
|
# 5. Restores the original csproj so no active Protobuf item is left behind.
|
||||||
|
#
|
||||||
|
# Only the SELECTED protos get a Protobuf item. Enabling one whose generated C#
|
||||||
|
# is still checked in would define every generated type twice, which is why the
|
||||||
|
# per-proto selection exists. central_control.proto imports sitestream.proto,
|
||||||
|
# but protoc resolves that from the project-relative path — the import needs no
|
||||||
|
# Protobuf item of its own.
|
||||||
#
|
#
|
||||||
# Once we move to a Dockerfile base image that ships a working linux/arm64
|
# Once we move to a Dockerfile base image that ships a working linux/arm64
|
||||||
# protoc, this script can be retired and Docker can regen the proto on every
|
# protoc, this script can be retired and Docker can regen the protos on every
|
||||||
# build like every other normal .NET project.
|
# build like every other normal .NET project.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET="${1:-all}"
|
||||||
|
case "$TARGET" in
|
||||||
|
sitestream|centralcontrol|all) ;;
|
||||||
|
*) echo "usage: $0 [sitestream|centralcontrol|all]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
COMM_DIR="$REPO_ROOT/src/ZB.MOM.WW.ScadaBridge.Communication"
|
COMM_DIR="$REPO_ROOT/src/ZB.MOM.WW.ScadaBridge.Communication"
|
||||||
CSPROJ="$COMM_DIR/ZB.MOM.WW.ScadaBridge.Communication.csproj"
|
CSPROJ="$COMM_DIR/ZB.MOM.WW.ScadaBridge.Communication.csproj"
|
||||||
GEN_DIR="$COMM_DIR/SiteStreamGrpc"
|
GEN="$COMM_DIR/obj/Debug/net10.0/Protos"
|
||||||
|
|
||||||
echo "=== Regenerating gRPC files from sitestream.proto ==="
|
echo "=== Regenerating gRPC files ($TARGET) ==="
|
||||||
|
|
||||||
if [[ ! -f "$CSPROJ" ]]; then
|
if [[ ! -f "$CSPROJ" ]]; then
|
||||||
echo "ERROR: csproj not found at $CSPROJ" >&2
|
echo "ERROR: csproj not found at $CSPROJ" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Backup so we can always restore the comment state on failure.
|
# Backup so we can always restore the comment state on failure. Leaving the
|
||||||
|
# csproj with an active Protobuf item is the one outcome that breaks Docker, so
|
||||||
|
# every exit path restores this copy.
|
||||||
BACKUP="$(mktemp)"
|
BACKUP="$(mktemp)"
|
||||||
cp "$CSPROJ" "$BACKUP"
|
cp "$CSPROJ" "$BACKUP"
|
||||||
trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR
|
trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR
|
||||||
|
|
||||||
# 1. Uncomment the Protobuf ItemGroup (strip the surrounding <!-- ... --> wrapper).
|
# 1. Inject an ItemGroup holding just the selected protos, immediately before
|
||||||
python3 - <<PY
|
# the closing </Project>. The documented commented-out block is left alone.
|
||||||
import re, pathlib
|
python3 - "$CSPROJ" "$TARGET" <<'PY'
|
||||||
p = pathlib.Path("$CSPROJ")
|
import pathlib, sys
|
||||||
src = p.read_text()
|
|
||||||
# Find the commented Protobuf block and unwrap it.
|
csproj, target = pathlib.Path(sys.argv[1]), sys.argv[2]
|
||||||
new = re.sub(
|
|
||||||
r"<!--\s*\n(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)\s*\n\s*-->",
|
protos = []
|
||||||
r"\1",
|
if target in ("sitestream", "all"):
|
||||||
src,
|
protos.append("sitestream.proto")
|
||||||
count=1,
|
if target in ("centralcontrol", "all"):
|
||||||
)
|
protos.append("central_control.proto")
|
||||||
if new == src:
|
|
||||||
raise SystemExit("Couldn't find commented Protobuf ItemGroup to enable.")
|
items = "\n".join(
|
||||||
p.write_text(new)
|
f' <Protobuf Include="Protos\\{p}" GrpcServices="Both" />' for p in protos)
|
||||||
|
block = f" <ItemGroup>\n{items}\n </ItemGroup>\n\n</Project>"
|
||||||
|
|
||||||
|
src = csproj.read_text()
|
||||||
|
if "</Project>" not in src:
|
||||||
|
raise SystemExit("Couldn't find </Project> to inject the Protobuf ItemGroup before.")
|
||||||
|
csproj.write_text(src.replace("</Project>", block, 1))
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# 2. Delete the stale files so any failure to regen is obvious.
|
# 2. Delete the stale files so any failure to regen is obvious.
|
||||||
rm -f "$GEN_DIR/Sitestream.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
if [[ "$TARGET" == "sitestream" || "$TARGET" == "all" ]]; then
|
||||||
|
rm -f "$COMM_DIR/SiteStreamGrpc/Sitestream.cs" "$COMM_DIR/SiteStreamGrpc/SitestreamGrpc.cs"
|
||||||
|
fi
|
||||||
|
if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then
|
||||||
|
rm -f "$COMM_DIR/CentralControlGrpc/CentralControl.cs" \
|
||||||
|
"$COMM_DIR/CentralControlGrpc/CentralControlGrpc.cs"
|
||||||
|
fi
|
||||||
|
|
||||||
# 3. Regenerate by building.
|
# 3. Regenerate by building.
|
||||||
echo "Building Communication project (regen)..."
|
echo "Building Communication project (regen)..."
|
||||||
dotnet build "$CSPROJ" --nologo -v minimal | tail -5
|
dotnet build "$CSPROJ" --nologo -v minimal | tail -5
|
||||||
|
|
||||||
# 4. Copy generated files back into the source tree.
|
# 4. Copy generated files back into the source tree.
|
||||||
mkdir -p "$GEN_DIR"
|
if [[ "$TARGET" == "sitestream" || "$TARGET" == "all" ]]; then
|
||||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/Sitestream.cs" "$GEN_DIR/Sitestream.cs"
|
mkdir -p "$COMM_DIR/SiteStreamGrpc"
|
||||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/SitestreamGrpc.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
cp "$GEN/Sitestream.cs" "$GEN/SitestreamGrpc.cs" "$COMM_DIR/SiteStreamGrpc/"
|
||||||
echo "Copied regenerated files to $GEN_DIR/"
|
echo "Copied regenerated files to SiteStreamGrpc/"
|
||||||
|
fi
|
||||||
# 5. Re-comment the Protobuf ItemGroup so Docker builds keep working.
|
if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then
|
||||||
python3 - <<PY
|
mkdir -p "$COMM_DIR/CentralControlGrpc"
|
||||||
import re, pathlib
|
cp "$GEN/CentralControl.cs" "$GEN/CentralControlGrpc.cs" "$COMM_DIR/CentralControlGrpc/"
|
||||||
p = pathlib.Path("$CSPROJ")
|
echo "Copied regenerated files to CentralControlGrpc/"
|
||||||
src = p.read_text()
|
fi
|
||||||
new = re.sub(
|
|
||||||
r"(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)",
|
|
||||||
r"\n <!--\1\n -->",
|
|
||||||
src,
|
|
||||||
count=1,
|
|
||||||
)
|
|
||||||
p.write_text(new)
|
|
||||||
PY
|
|
||||||
|
|
||||||
|
# 5. Restore the backed-up csproj — i.e. drop the injected ItemGroup — so Docker
|
||||||
|
# builds keep working.
|
||||||
|
cp "$BACKUP" "$CSPROJ"
|
||||||
rm -f "$BACKUP"
|
rm -f "$BACKUP"
|
||||||
trap - ERR
|
trap - ERR
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done. Review and commit:"
|
echo "Done. Review and commit:"
|
||||||
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto"
|
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/Protos/"
|
||||||
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/"
|
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/"
|
||||||
|
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/"
|
||||||
|
echo " git diff -- src/ZB.MOM.WW.ScadaBridge.Communication/*.csproj # must be EMPTY"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,704 @@
|
|||||||
|
// <auto-generated>
|
||||||
|
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
// source: Protos/central_control.proto
|
||||||
|
// </auto-generated>
|
||||||
|
#pragma warning disable 0414, 1591, 8981, 0612
|
||||||
|
#region Designer generated code
|
||||||
|
|
||||||
|
using grpc = global::Grpc.Core;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||||
|
/// <summary>
|
||||||
|
/// Central-hosted control plane (Phase 1A of the ClusterClient→gRPC migration).
|
||||||
|
///
|
||||||
|
/// Direction: SITE is the client, CENTRAL is the server — the inverse of
|
||||||
|
/// SiteStreamService, where central dials the site. That asymmetry is deliberate
|
||||||
|
/// and mirrors the direction the Akka ClusterClient traffic flows today: these
|
||||||
|
/// seven calls are exactly the seven messages SiteCommunicationActor sends to
|
||||||
|
/// /user/central-communication.
|
||||||
|
///
|
||||||
|
/// Every call is gated by ControlPlaneAuthInterceptor: `authorization: Bearer <psk>`
|
||||||
|
/// plus the `x-scadabridge-site` metadata header naming which site's preshared key
|
||||||
|
/// central must verify against.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class CentralControlService
|
||||||
|
{
|
||||||
|
static readonly string __ServiceName = "scadabridge.centralcontrol.v1.CentralControlService";
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context)
|
||||||
|
{
|
||||||
|
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
|
||||||
|
if (message is global::Google.Protobuf.IBufferMessage)
|
||||||
|
{
|
||||||
|
context.SetPayloadLength(message.CalculateSize());
|
||||||
|
global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter());
|
||||||
|
context.Complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static class __Helper_MessageCache<T>
|
||||||
|
{
|
||||||
|
public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static T __Helper_DeserializeMessage<T>(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser<T> parser) where T : global::Google.Protobuf.IMessage<T>
|
||||||
|
{
|
||||||
|
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
|
||||||
|
if (__Helper_MessageCache<T>.IsBufferMessage)
|
||||||
|
{
|
||||||
|
return parser.ParseFrom(context.PayloadAsReadOnlySequence());
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return parser.ParseFrom(context.PayloadAsNewBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto> __Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto> __Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitAckDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto> __Marshaller_scadabridge_centralcontrol_v1_NotificationStatusQueryDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto> __Marshaller_scadabridge_centralcontrol_v1_NotificationStatusResponseDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch> __Marshaller_sitestream_AuditEventBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> __Marshaller_sitestream_IngestAck = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch> __Marshaller_sitestream_CachedTelemetryBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto> __Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteRequestDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto> __Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteResponseDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto> __Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto> __Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportAckDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto> __Marshaller_scadabridge_centralcontrol_v1_HeartbeatDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto.Parser));
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Marshaller<global::Google.Protobuf.WellKnownTypes.Empty> __Marshaller_google_protobuf_Empty = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::Google.Protobuf.WellKnownTypes.Empty.Parser));
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto> __Method_SubmitNotification = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"SubmitNotification",
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitDto,
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitAckDto);
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto> __Method_QueryNotificationStatus = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"QueryNotificationStatus",
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_NotificationStatusQueryDto,
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_NotificationStatusResponseDto);
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> __Method_IngestAuditEvents = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"IngestAuditEvents",
|
||||||
|
__Marshaller_sitestream_AuditEventBatch,
|
||||||
|
__Marshaller_sitestream_IngestAck);
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> __Method_IngestCachedTelemetry = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"IngestCachedTelemetry",
|
||||||
|
__Marshaller_sitestream_CachedTelemetryBatch,
|
||||||
|
__Marshaller_sitestream_IngestAck);
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto> __Method_ReconcileSite = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"ReconcileSite",
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteRequestDto,
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteResponseDto);
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto> __Method_ReportSiteHealth = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"ReportSiteHealth",
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportDto,
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportAckDto);
|
||||||
|
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto, global::Google.Protobuf.WellKnownTypes.Empty> __Method_Heartbeat = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto, global::Google.Protobuf.WellKnownTypes.Empty>(
|
||||||
|
grpc::MethodType.Unary,
|
||||||
|
__ServiceName,
|
||||||
|
"Heartbeat",
|
||||||
|
__Marshaller_scadabridge_centralcontrol_v1_HeartbeatDto,
|
||||||
|
__Marshaller_google_protobuf_Empty);
|
||||||
|
|
||||||
|
/// <summary>Service descriptor</summary>
|
||||||
|
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
|
||||||
|
{
|
||||||
|
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.Services[0]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Base class for server-side implementations of CentralControlService</summary>
|
||||||
|
[grpc::BindServiceMethod(typeof(CentralControlService), "BindService")]
|
||||||
|
public abstract partial class CentralControlServiceBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Store-and-forward handoff of one notification for central delivery. The
|
||||||
|
/// ack is idempotent on notification_id — a duplicate submit after a lost ack
|
||||||
|
/// must not produce a second delivery.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto> SubmitNotification(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notify.Status(id) round-trip for a notification that has already left the
|
||||||
|
/// site buffer. `found = false` sends the caller back to the site-local buffer
|
||||||
|
/// to decide Forwarding vs Unknown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto> QueryNotificationStatus(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
|
||||||
|
/// batch a site drains from its SQLite hot path is byte-identical whichever
|
||||||
|
/// transport carries it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> IngestAuditEvents(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
|
||||||
|
/// operational upsert, written in one central transaction).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> IngestCachedTelemetry(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Node-startup self-heal: the node's local deployed inventory in, fetch
|
||||||
|
/// tokens for whatever it is missing or stale out.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto> ReconcileSite(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic site health report (30 s cadence). The ack makes delivery
|
||||||
|
/// observable end-to-end so the sender can restore its per-interval counters
|
||||||
|
/// when a report is lost.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto> ReportSiteHealth(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Application heartbeat. Returns Empty because the message is
|
||||||
|
/// fire-and-forget: nothing on the site consumes a reply, and a failure here
|
||||||
|
/// must never surface as a fault on the heartbeat timer path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request received from the client.</param>
|
||||||
|
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||||
|
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::System.Threading.Tasks.Task<global::Google.Protobuf.WellKnownTypes.Empty> Heartbeat(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::ServerCallContext context)
|
||||||
|
{
|
||||||
|
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Client for CentralControlService</summary>
|
||||||
|
public partial class CentralControlServiceClient : grpc::ClientBase<CentralControlServiceClient>
|
||||||
|
{
|
||||||
|
/// <summary>Creates a new client for CentralControlService</summary>
|
||||||
|
/// <param name="channel">The channel to use to make remote calls.</param>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public CentralControlServiceClient(grpc::ChannelBase channel) : base(channel)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
/// <summary>Creates a new client for CentralControlService that uses a custom <c>CallInvoker</c>.</summary>
|
||||||
|
/// <param name="callInvoker">The callInvoker to use to make remote calls.</param>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public CentralControlServiceClient(grpc::CallInvoker callInvoker) : base(callInvoker)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
/// <summary>Protected parameterless constructor to allow creation of test doubles.</summary>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
protected CentralControlServiceClient() : base()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
/// <summary>Protected constructor to allow creation of configured clients.</summary>
|
||||||
|
/// <param name="configuration">The client configuration.</param>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
protected CentralControlServiceClient(ClientBaseConfiguration configuration) : base(configuration)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store-and-forward handoff of one notification for central delivery. The
|
||||||
|
/// ack is idempotent on notification_id — a duplicate submit after a lost ack
|
||||||
|
/// must not produce a second delivery.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto SubmitNotification(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return SubmitNotification(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Store-and-forward handoff of one notification for central delivery. The
|
||||||
|
/// ack is idempotent on notification_id — a duplicate submit after a lost ack
|
||||||
|
/// must not produce a second delivery.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto SubmitNotification(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_SubmitNotification, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Store-and-forward handoff of one notification for central delivery. The
|
||||||
|
/// ack is idempotent on notification_id — a duplicate submit after a lost ack
|
||||||
|
/// must not produce a second delivery.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto> SubmitNotificationAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return SubmitNotificationAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Store-and-forward handoff of one notification for central delivery. The
|
||||||
|
/// ack is idempotent on notification_id — a duplicate submit after a lost ack
|
||||||
|
/// must not produce a second delivery.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto> SubmitNotificationAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_SubmitNotification, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Notify.Status(id) round-trip for a notification that has already left the
|
||||||
|
/// site buffer. `found = false` sends the caller back to the site-local buffer
|
||||||
|
/// to decide Forwarding vs Unknown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto QueryNotificationStatus(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return QueryNotificationStatus(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Notify.Status(id) round-trip for a notification that has already left the
|
||||||
|
/// site buffer. `found = false` sends the caller back to the site-local buffer
|
||||||
|
/// to decide Forwarding vs Unknown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto QueryNotificationStatus(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_QueryNotificationStatus, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Notify.Status(id) round-trip for a notification that has already left the
|
||||||
|
/// site buffer. `found = false` sends the caller back to the site-local buffer
|
||||||
|
/// to decide Forwarding vs Unknown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto> QueryNotificationStatusAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return QueryNotificationStatusAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Notify.Status(id) round-trip for a notification that has already left the
|
||||||
|
/// site buffer. `found = false` sends the caller back to the site-local buffer
|
||||||
|
/// to decide Forwarding vs Unknown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto> QueryNotificationStatusAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_QueryNotificationStatus, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
|
||||||
|
/// batch a site drains from its SQLite hot path is byte-identical whichever
|
||||||
|
/// transport carries it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestAuditEvents(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return IngestAuditEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
|
||||||
|
/// batch a site drains from its SQLite hot path is byte-identical whichever
|
||||||
|
/// transport carries it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestAuditEvents(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_IngestAuditEvents, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
|
||||||
|
/// batch a site drains from its SQLite hot path is byte-identical whichever
|
||||||
|
/// transport carries it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> IngestAuditEventsAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return IngestAuditEventsAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
|
||||||
|
/// batch a site drains from its SQLite hot path is byte-identical whichever
|
||||||
|
/// transport carries it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> IngestAuditEventsAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_IngestAuditEvents, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
|
||||||
|
/// operational upsert, written in one central transaction).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return IngestCachedTelemetry(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
|
||||||
|
/// operational upsert, written in one central transaction).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_IngestCachedTelemetry, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
|
||||||
|
/// operational upsert, written in one central transaction).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> IngestCachedTelemetryAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return IngestCachedTelemetryAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
|
||||||
|
/// operational upsert, written in one central transaction).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck> IngestCachedTelemetryAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_IngestCachedTelemetry, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Node-startup self-heal: the node's local deployed inventory in, fetch
|
||||||
|
/// tokens for whatever it is missing or stale out.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto ReconcileSite(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return ReconcileSite(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Node-startup self-heal: the node's local deployed inventory in, fetch
|
||||||
|
/// tokens for whatever it is missing or stale out.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto ReconcileSite(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_ReconcileSite, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Node-startup self-heal: the node's local deployed inventory in, fetch
|
||||||
|
/// tokens for whatever it is missing or stale out.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto> ReconcileSiteAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return ReconcileSiteAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Node-startup self-heal: the node's local deployed inventory in, fetch
|
||||||
|
/// tokens for whatever it is missing or stale out.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto> ReconcileSiteAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_ReconcileSite, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic site health report (30 s cadence). The ack makes delivery
|
||||||
|
/// observable end-to-end so the sender can restore its per-interval counters
|
||||||
|
/// when a report is lost.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto ReportSiteHealth(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return ReportSiteHealth(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic site health report (30 s cadence). The ack makes delivery
|
||||||
|
/// observable end-to-end so the sender can restore its per-interval counters
|
||||||
|
/// when a report is lost.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto ReportSiteHealth(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_ReportSiteHealth, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic site health report (30 s cadence). The ack makes delivery
|
||||||
|
/// observable end-to-end so the sender can restore its per-interval counters
|
||||||
|
/// when a report is lost.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto> ReportSiteHealthAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return ReportSiteHealthAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic site health report (30 s cadence). The ack makes delivery
|
||||||
|
/// observable end-to-end so the sender can restore its per-interval counters
|
||||||
|
/// when a report is lost.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto> ReportSiteHealthAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_ReportSiteHealth, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Application heartbeat. Returns Empty because the message is
|
||||||
|
/// fire-and-forget: nothing on the site consumes a reply, and a failure here
|
||||||
|
/// must never surface as a fault on the heartbeat timer path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::Google.Protobuf.WellKnownTypes.Empty Heartbeat(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return Heartbeat(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Application heartbeat. Returns Empty because the message is
|
||||||
|
/// fire-and-forget: nothing on the site consumes a reply, and a failure here
|
||||||
|
/// must never surface as a fault on the heartbeat timer path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The response received from the server.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual global::Google.Protobuf.WellKnownTypes.Empty Heartbeat(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.BlockingUnaryCall(__Method_Heartbeat, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Application heartbeat. Returns Empty because the message is
|
||||||
|
/// fire-and-forget: nothing on the site consumes a reply, and a failure here
|
||||||
|
/// must never surface as a fault on the heartbeat timer path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||||
|
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||||
|
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::Google.Protobuf.WellKnownTypes.Empty> HeartbeatAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||||
|
{
|
||||||
|
return HeartbeatAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Application heartbeat. Returns Empty because the message is
|
||||||
|
/// fire-and-forget: nothing on the site consumes a reply, and a failure here
|
||||||
|
/// must never surface as a fault on the heartbeat timer path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to send to the server.</param>
|
||||||
|
/// <param name="options">The options for the call.</param>
|
||||||
|
/// <returns>The call object.</returns>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public virtual grpc::AsyncUnaryCall<global::Google.Protobuf.WellKnownTypes.Empty> HeartbeatAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::CallOptions options)
|
||||||
|
{
|
||||||
|
return CallInvoker.AsyncUnaryCall(__Method_Heartbeat, null, options, request);
|
||||||
|
}
|
||||||
|
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
protected override CentralControlServiceClient NewInstance(ClientBaseConfiguration configuration)
|
||||||
|
{
|
||||||
|
return new CentralControlServiceClient(configuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates service definition that can be registered with a server</summary>
|
||||||
|
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public static grpc::ServerServiceDefinition BindService(CentralControlServiceBase serviceImpl)
|
||||||
|
{
|
||||||
|
return grpc::ServerServiceDefinition.CreateBuilder()
|
||||||
|
.AddMethod(__Method_SubmitNotification, serviceImpl.SubmitNotification)
|
||||||
|
.AddMethod(__Method_QueryNotificationStatus, serviceImpl.QueryNotificationStatus)
|
||||||
|
.AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents)
|
||||||
|
.AddMethod(__Method_IngestCachedTelemetry, serviceImpl.IngestCachedTelemetry)
|
||||||
|
.AddMethod(__Method_ReconcileSite, serviceImpl.ReconcileSite)
|
||||||
|
.AddMethod(__Method_ReportSiteHealth, serviceImpl.ReportSiteHealth)
|
||||||
|
.AddMethod(__Method_Heartbeat, serviceImpl.Heartbeat).Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
|
||||||
|
/// Note: this method is part of an experimental API that can change or be removed without any prior notice.</summary>
|
||||||
|
/// <param name="serviceBinder">Service methods will be bound by calling <c>AddMethod</c> on this object.</param>
|
||||||
|
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||||
|
public static void BindService(grpc::ServiceBinderBase serviceBinder, CentralControlServiceBase serviceImpl)
|
||||||
|
{
|
||||||
|
serviceBinder.AddMethod(__Method_SubmitNotification, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto>(serviceImpl.SubmitNotification));
|
||||||
|
serviceBinder.AddMethod(__Method_QueryNotificationStatus, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto>(serviceImpl.QueryNotificationStatus));
|
||||||
|
serviceBinder.AddMethod(__Method_IngestAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck>(serviceImpl.IngestAuditEvents));
|
||||||
|
serviceBinder.AddMethod(__Method_IngestCachedTelemetry, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck>(serviceImpl.IngestCachedTelemetry));
|
||||||
|
serviceBinder.AddMethod(__Method_ReconcileSite, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto>(serviceImpl.ReconcileSite));
|
||||||
|
serviceBinder.AddMethod(__Method_ReportSiteHealth, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto>(serviceImpl.ReportSiteHealth));
|
||||||
|
serviceBinder.AddMethod(__Method_Heartbeat, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto, global::Google.Protobuf.WellKnownTypes.Empty>(serviceImpl.Heartbeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical bridge between the seven in-process messages the site sends to central
|
||||||
|
/// and the wire format of <c>CentralControlService</c> (<c>Protos/central_control.proto</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The seven pairs mirror, one for one, the seven messages
|
||||||
|
/// <c>SiteCommunicationActor</c> forwards to <c>/user/central-communication</c> over
|
||||||
|
/// Akka <c>ClusterClient</c> today. Both transports carry the SAME message types
|
||||||
|
/// end-to-end — central's handlers are untouched by the migration — so this mapper is
|
||||||
|
/// the only place the two representations meet, and a field that does not survive a
|
||||||
|
/// round-trip here is a field the gRPC transport silently drops.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ingest is deliberately absent from the message list.</b> <c>IngestAuditEvents</c>
|
||||||
|
/// and <c>IngestCachedTelemetry</c> reuse the <c>AuditEventBatch</c> /
|
||||||
|
/// <c>CachedTelemetryBatch</c> / <c>IngestAck</c> messages already defined for the
|
||||||
|
/// site-hosted <c>SiteStreamService</c>, so the per-row work is delegated to the
|
||||||
|
/// existing <see cref="AuditEventDtoMapper"/> and <see cref="SiteCallDtoMapper"/>; only
|
||||||
|
/// the batch/ack envelopes are assembled here.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para><b>Conventions, applied uniformly across every method below.</b></para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <b>Nullable strings ↔ empty strings.</b> A proto3 scalar string cannot be absent,
|
||||||
|
/// so a null .NET string is written as <see cref="string.Empty"/> and an empty wire
|
||||||
|
/// string is read back as <see langword="null"/>. This is the convention already in
|
||||||
|
/// force on <see cref="AuditEventDtoMapper"/>, and it is why no field on this wire
|
||||||
|
/// may distinguish "null" from "deliberately empty".
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>Nullable <see cref="Guid"/> ↔ string.</b> Execution ids travel as their "D"
|
||||||
|
/// string form; the empty string means <see langword="null"/>. A malformed non-empty
|
||||||
|
/// value throws out of <c>FromDto</c> rather than degrading to null — a corrupt
|
||||||
|
/// correlation id must not be laundered into "no correlation".
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>Nullable numbers and booleans ↔ protobuf wrapper types.</b>
|
||||||
|
/// <c>Int32Value</c>/<c>Int64Value</c>/<c>DoubleValue</c>/<c>BoolValue</c> preserve
|
||||||
|
/// true null. Several health gauges (<c>LocalDbOplogBacklog</c>,
|
||||||
|
/// <c>LocalDbReplicationConnected</c>) are documented as "null means unknown, and
|
||||||
|
/// that is NOT the same as zero/false"; collapsing them to a bare scalar would
|
||||||
|
/// report a broken replication pair as healthy.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>Nullable collections ↔ wrapper messages.</b> proto3 cannot express presence on
|
||||||
|
/// a <c>repeated</c> or <c>map</c> field, so the three nullable
|
||||||
|
/// <see cref="SiteHealthReport"/> collections travel inside single-field wrapper
|
||||||
|
/// messages (<c>ConnectionEndpointMapDto</c>, <c>TagQualityMapDto</c>,
|
||||||
|
/// <c>NodeStatusListDto</c>). An absent wrapper is null; a present-but-empty wrapper
|
||||||
|
/// is an empty collection.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b><see cref="DateTimeOffset"/> normalizes to a UTC instant.</b> A protobuf
|
||||||
|
/// <c>Timestamp</c> is an instant, not an offset-qualified local time, so the offset
|
||||||
|
/// component is dropped and the value round-trips with <c>Offset == TimeSpan.Zero</c>.
|
||||||
|
/// Every producer in this system stamps UTC (the repo-wide invariant; e.g.
|
||||||
|
/// <c>Notify.Send</c> uses <c>DateTimeOffset.UtcNow</c>), so this is lossless in
|
||||||
|
/// practice and the instant is preserved regardless.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CentralControlDtoMapper
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Notification Outbox (#21)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The notification submission to project.</param>
|
||||||
|
/// <returns>The wire-format DTO; null strings and null execution ids collapse to empty strings.</returns>
|
||||||
|
public static NotificationSubmitDto ToDto(NotificationSubmit msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
return new NotificationSubmitDto
|
||||||
|
{
|
||||||
|
NotificationId = msg.NotificationId,
|
||||||
|
ListName = msg.ListName,
|
||||||
|
Subject = msg.Subject,
|
||||||
|
Body = msg.Body,
|
||||||
|
SourceSiteId = msg.SourceSiteId,
|
||||||
|
SourceInstanceId = msg.SourceInstanceId ?? string.Empty,
|
||||||
|
SourceScript = msg.SourceScript ?? string.Empty,
|
||||||
|
SiteEnqueuedAt = Timestamp.FromDateTimeOffset(msg.SiteEnqueuedAt),
|
||||||
|
OriginExecutionId = GuidToWire(msg.OriginExecutionId),
|
||||||
|
OriginParentExecutionId = GuidToWire(msg.OriginParentExecutionId),
|
||||||
|
SourceNode = msg.SourceNode ?? string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="NotificationSubmit"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process message; empty strings rehydrate as null.</returns>
|
||||||
|
public static NotificationSubmit FromDto(NotificationSubmitDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new NotificationSubmit(
|
||||||
|
NotificationId: dto.NotificationId,
|
||||||
|
ListName: dto.ListName,
|
||||||
|
Subject: dto.Subject,
|
||||||
|
Body: dto.Body,
|
||||||
|
SourceSiteId: dto.SourceSiteId,
|
||||||
|
SourceInstanceId: NullIfEmpty(dto.SourceInstanceId),
|
||||||
|
SourceScript: NullIfEmpty(dto.SourceScript),
|
||||||
|
SiteEnqueuedAt: dto.SiteEnqueuedAt.ToDateTimeOffset(),
|
||||||
|
OriginExecutionId: GuidFromWire(dto.OriginExecutionId),
|
||||||
|
OriginParentExecutionId: GuidFromWire(dto.OriginParentExecutionId),
|
||||||
|
SourceNode: NullIfEmpty(dto.SourceNode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="NotificationSubmitAck"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The ack to project.</param>
|
||||||
|
/// <returns>The wire-format DTO; a null error collapses to an empty string.</returns>
|
||||||
|
public static NotificationSubmitAckDto ToDto(NotificationSubmitAck msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
return new NotificationSubmitAckDto
|
||||||
|
{
|
||||||
|
NotificationId = msg.NotificationId,
|
||||||
|
Accepted = msg.Accepted,
|
||||||
|
Error = msg.Error ?? string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="NotificationSubmitAck"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process ack; an empty error rehydrates as null.</returns>
|
||||||
|
public static NotificationSubmitAck FromDto(NotificationSubmitAckDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new NotificationSubmitAck(
|
||||||
|
NotificationId: dto.NotificationId,
|
||||||
|
Accepted: dto.Accepted,
|
||||||
|
Error: NullIfEmpty(dto.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="NotificationStatusQuery"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The status query to project.</param>
|
||||||
|
/// <returns>The wire-format DTO.</returns>
|
||||||
|
public static NotificationStatusQueryDto ToDto(NotificationStatusQuery msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
return new NotificationStatusQueryDto
|
||||||
|
{
|
||||||
|
CorrelationId = msg.CorrelationId,
|
||||||
|
NotificationId = msg.NotificationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="NotificationStatusQuery"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process query.</returns>
|
||||||
|
public static NotificationStatusQuery FromDto(NotificationStatusQueryDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new NotificationStatusQuery(
|
||||||
|
CorrelationId: dto.CorrelationId,
|
||||||
|
NotificationId: dto.NotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="NotificationStatusResponse"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The status response to project.</param>
|
||||||
|
/// <returns>The wire-format DTO; a null delivery timestamp leaves the field unset.</returns>
|
||||||
|
public static NotificationStatusResponseDto ToDto(NotificationStatusResponse msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
var dto = new NotificationStatusResponseDto
|
||||||
|
{
|
||||||
|
CorrelationId = msg.CorrelationId,
|
||||||
|
Found = msg.Found,
|
||||||
|
Status = msg.Status,
|
||||||
|
RetryCount = msg.RetryCount,
|
||||||
|
LastError = msg.LastError ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.DeliveredAt.HasValue)
|
||||||
|
{
|
||||||
|
dto.DeliveredAt = Timestamp.FromDateTimeOffset(msg.DeliveredAt.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="NotificationStatusResponse"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process response; an unset delivery timestamp rehydrates as null.</returns>
|
||||||
|
public static NotificationStatusResponse FromDto(NotificationStatusResponseDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new NotificationStatusResponse(
|
||||||
|
CorrelationId: dto.CorrelationId,
|
||||||
|
Found: dto.Found,
|
||||||
|
Status: dto.Status,
|
||||||
|
RetryCount: dto.RetryCount,
|
||||||
|
LastError: NullIfEmpty(dto.LastError),
|
||||||
|
DeliveredAt: dto.DeliveredAt?.ToDateTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Audit Log (#23) ingest — envelopes only; rows go through the existing mappers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects an <see cref="IngestAuditEventsCommand"/> onto the shared
|
||||||
|
/// <see cref="AuditEventBatch"/> wire message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The per-row projection is <see cref="AuditEventDtoMapper.ToDto"/>, which is lossy
|
||||||
|
/// by design: <c>ForwardState</c> is site-local storage state and <c>IngestedAtUtc</c>
|
||||||
|
/// is stamped centrally at ingest, so neither travels.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="cmd">The ingest command to project.</param>
|
||||||
|
/// <returns>A batch carrying one DTO per audit event, in order.</returns>
|
||||||
|
public static AuditEventBatch ToDto(IngestAuditEventsCommand cmd)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(cmd);
|
||||||
|
|
||||||
|
var batch = new AuditEventBatch();
|
||||||
|
foreach (var evt in cmd.Events)
|
||||||
|
{
|
||||||
|
batch.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs an <see cref="IngestAuditEventsCommand"/> from the shared
|
||||||
|
/// <see cref="AuditEventBatch"/> wire message — the shape central's
|
||||||
|
/// <c>CentralCommunicationActor</c> already handles.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="batch">The wire batch to reconstruct.</param>
|
||||||
|
/// <returns>The in-process ingest command.</returns>
|
||||||
|
public static IngestAuditEventsCommand FromDto(AuditEventBatch batch)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
|
|
||||||
|
var events = new List<ZB.MOM.WW.Audit.AuditEvent>(batch.Events.Count);
|
||||||
|
foreach (var dto in batch.Events)
|
||||||
|
{
|
||||||
|
events.Add(AuditEventDtoMapper.FromDto(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IngestAuditEventsCommand(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects an <see cref="IngestCachedTelemetryCommand"/> onto the shared
|
||||||
|
/// <see cref="CachedTelemetryBatch"/> wire message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cmd">The cached-telemetry ingest command to project.</param>
|
||||||
|
/// <returns>A batch carrying one packet (audit row + operational row) per entry, in order.</returns>
|
||||||
|
public static CachedTelemetryBatch ToDto(IngestCachedTelemetryCommand cmd)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(cmd);
|
||||||
|
|
||||||
|
var batch = new CachedTelemetryBatch();
|
||||||
|
foreach (var entry in cmd.Entries)
|
||||||
|
{
|
||||||
|
batch.Packets.Add(new CachedTelemetryPacket
|
||||||
|
{
|
||||||
|
AuditEvent = AuditEventDtoMapper.ToDto(entry.Audit),
|
||||||
|
Operational = SiteCallDtoMapper.ToDto(entry.SiteCall),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs an <see cref="IngestCachedTelemetryCommand"/> from the shared
|
||||||
|
/// <see cref="CachedTelemetryBatch"/> wire message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="batch">The wire batch to reconstruct.</param>
|
||||||
|
/// <returns>The in-process dual-write ingest command.</returns>
|
||||||
|
public static IngestCachedTelemetryCommand FromDto(CachedTelemetryBatch batch)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
|
|
||||||
|
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
|
||||||
|
foreach (var packet in batch.Packets)
|
||||||
|
{
|
||||||
|
entries.Add(new CachedTelemetryEntry(
|
||||||
|
AuditEventDtoMapper.FromDto(packet.AuditEvent),
|
||||||
|
SiteCallDtoMapper.FromDto(packet.Operational)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IngestCachedTelemetryCommand(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects the accepted-id list of an ingest reply onto the shared
|
||||||
|
/// <see cref="IngestAck"/> wire message. Shared by both ingest RPCs — the two
|
||||||
|
/// central reply types differ only in which handler produced them.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="acceptedEventIds">Ids central considers durably persisted.</param>
|
||||||
|
/// <returns>The wire ack carrying the ids in "D" string form, in order.</returns>
|
||||||
|
public static IngestAck ToIngestAck(IReadOnlyList<Guid> acceptedEventIds)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(acceptedEventIds);
|
||||||
|
|
||||||
|
var ack = new IngestAck();
|
||||||
|
foreach (var id in acceptedEventIds)
|
||||||
|
{
|
||||||
|
ack.AcceptedEventIds.Add(id.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the accepted-id list back out of an <see cref="IngestAck"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ack">The wire ack to read.</param>
|
||||||
|
/// <returns>The accepted event ids, in wire order.</returns>
|
||||||
|
public static IReadOnlyList<Guid> FromIngestAck(IngestAck ack)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ack);
|
||||||
|
|
||||||
|
var ids = new List<Guid>(ack.AcceptedEventIds.Count);
|
||||||
|
foreach (var id in ack.AcceptedEventIds)
|
||||||
|
{
|
||||||
|
ids.Add(Guid.Parse(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Startup reconciliation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="ReconcileSiteRequest"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The reconcile request to project.</param>
|
||||||
|
/// <returns>The wire-format DTO carrying the node's local name→revision-hash inventory.</returns>
|
||||||
|
public static ReconcileSiteRequestDto ToDto(ReconcileSiteRequest msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
var dto = new ReconcileSiteRequestDto
|
||||||
|
{
|
||||||
|
SiteIdentifier = msg.SiteIdentifier,
|
||||||
|
NodeId = msg.NodeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (name, hash) in msg.LocalNameToRevisionHash)
|
||||||
|
{
|
||||||
|
dto.LocalNameToRevisionHash[name] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="ReconcileSiteRequest"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process reconcile request.</returns>
|
||||||
|
public static ReconcileSiteRequest FromDto(ReconcileSiteRequestDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new ReconcileSiteRequest(
|
||||||
|
SiteIdentifier: dto.SiteIdentifier,
|
||||||
|
NodeId: dto.NodeId,
|
||||||
|
LocalNameToRevisionHash: new Dictionary<string, string>(dto.LocalNameToRevisionHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="ReconcileSiteResponse"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The reconcile response to project.</param>
|
||||||
|
/// <returns>The wire-format DTO carrying the gap items, orphan names and fetch base URL.</returns>
|
||||||
|
public static ReconcileSiteResponseDto ToDto(ReconcileSiteResponse msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
var dto = new ReconcileSiteResponseDto
|
||||||
|
{
|
||||||
|
CentralFetchBaseUrl = msg.CentralFetchBaseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in msg.Gap)
|
||||||
|
{
|
||||||
|
dto.Gap.Add(new ReconcileGapItemDto
|
||||||
|
{
|
||||||
|
InstanceUniqueName = item.InstanceUniqueName,
|
||||||
|
DeploymentId = item.DeploymentId,
|
||||||
|
RevisionHash = item.RevisionHash,
|
||||||
|
IsEnabled = item.IsEnabled,
|
||||||
|
FetchToken = item.FetchToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.OrphanNames.AddRange(msg.OrphanNames);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="ReconcileSiteResponse"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process reconcile response.</returns>
|
||||||
|
public static ReconcileSiteResponse FromDto(ReconcileSiteResponseDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
var gap = new List<ReconcileGapItem>(dto.Gap.Count);
|
||||||
|
foreach (var item in dto.Gap)
|
||||||
|
{
|
||||||
|
gap.Add(new ReconcileGapItem(
|
||||||
|
InstanceUniqueName: item.InstanceUniqueName,
|
||||||
|
DeploymentId: item.DeploymentId,
|
||||||
|
RevisionHash: item.RevisionHash,
|
||||||
|
IsEnabled: item.IsEnabled,
|
||||||
|
FetchToken: item.FetchToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReconcileSiteResponse(
|
||||||
|
Gap: gap,
|
||||||
|
OrphanNames: dto.OrphanNames.ToList(),
|
||||||
|
CentralFetchBaseUrl: dto.CentralFetchBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Health Monitoring (#11)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The three nullable collections travel inside wrapper messages so a null stays
|
||||||
|
/// distinguishable from an empty collection; the nullable gauges travel in protobuf
|
||||||
|
/// wrapper types for the same reason.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="msg">The health report to project.</param>
|
||||||
|
/// <returns>The wire-format DTO.</returns>
|
||||||
|
public static SiteHealthReportDto ToDto(SiteHealthReport msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
var dto = new SiteHealthReportDto
|
||||||
|
{
|
||||||
|
SiteId = msg.SiteId,
|
||||||
|
SequenceNumber = msg.SequenceNumber,
|
||||||
|
ReportTimestamp = Timestamp.FromDateTimeOffset(msg.ReportTimestamp),
|
||||||
|
ScriptErrorCount = msg.ScriptErrorCount,
|
||||||
|
AlarmEvaluationErrorCount = msg.AlarmEvaluationErrorCount,
|
||||||
|
DeadLetterCount = msg.DeadLetterCount,
|
||||||
|
DeployedInstanceCount = msg.DeployedInstanceCount,
|
||||||
|
EnabledInstanceCount = msg.EnabledInstanceCount,
|
||||||
|
DisabledInstanceCount = msg.DisabledInstanceCount,
|
||||||
|
NodeRole = msg.NodeRole,
|
||||||
|
NodeHostname = msg.NodeHostname,
|
||||||
|
ParkedMessageCount = msg.ParkedMessageCount,
|
||||||
|
SiteAuditWriteFailures = msg.SiteAuditWriteFailures,
|
||||||
|
AuditRedactionFailure = msg.AuditRedactionFailure,
|
||||||
|
SiteEventLogWriteFailures = msg.SiteEventLogWriteFailures,
|
||||||
|
OldestParkedMessageAgeSeconds = msg.OldestParkedMessageAgeSeconds,
|
||||||
|
ScriptQueueDepth = msg.ScriptQueueDepth,
|
||||||
|
ScriptBusyThreads = msg.ScriptBusyThreads,
|
||||||
|
ScriptOldestBusyAgeSeconds = msg.ScriptOldestBusyAgeSeconds,
|
||||||
|
LocalDbReplicationConnected = msg.LocalDbReplicationConnected,
|
||||||
|
LocalDbOplogBacklog = msg.LocalDbOplogBacklog,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (name, health) in msg.DataConnectionStatuses)
|
||||||
|
{
|
||||||
|
dto.DataConnectionStatuses[name] = ToDto(health);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (name, resolution) in msg.TagResolutionCounts)
|
||||||
|
{
|
||||||
|
dto.TagResolutionCounts[name] = new TagResolutionStatusDto
|
||||||
|
{
|
||||||
|
TotalSubscribed = resolution.TotalSubscribed,
|
||||||
|
SuccessfullyResolved = resolution.SuccessfullyResolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (name, depth) in msg.StoreAndForwardBufferDepths)
|
||||||
|
{
|
||||||
|
dto.StoreAndForwardBufferDepths[name] = depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.DataConnectionEndpoints is { } endpoints)
|
||||||
|
{
|
||||||
|
var wrapper = new ConnectionEndpointMapDto();
|
||||||
|
foreach (var (name, endpoint) in endpoints)
|
||||||
|
{
|
||||||
|
wrapper.Entries[name] = endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.DataConnectionEndpoints = wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.DataConnectionTagQuality is { } tagQuality)
|
||||||
|
{
|
||||||
|
var wrapper = new TagQualityMapDto();
|
||||||
|
foreach (var (name, counts) in tagQuality)
|
||||||
|
{
|
||||||
|
wrapper.Entries[name] = new TagQualityCountsDto
|
||||||
|
{
|
||||||
|
Good = counts.Good,
|
||||||
|
Bad = counts.Bad,
|
||||||
|
Uncertain = counts.Uncertain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.DataConnectionTagQuality = wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.ClusterNodes is { } clusterNodes)
|
||||||
|
{
|
||||||
|
var wrapper = new NodeStatusListDto();
|
||||||
|
foreach (var node in clusterNodes)
|
||||||
|
{
|
||||||
|
wrapper.Nodes.Add(new NodeStatusDto
|
||||||
|
{
|
||||||
|
Hostname = node.Hostname,
|
||||||
|
IsOnline = node.IsOnline,
|
||||||
|
Role = node.Role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.ClusterNodes = wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.SiteAuditBacklog is { } backlog)
|
||||||
|
{
|
||||||
|
var snapshot = new SiteAuditBacklogSnapshotDto
|
||||||
|
{
|
||||||
|
PendingCount = backlog.PendingCount,
|
||||||
|
OnDiskBytes = backlog.OnDiskBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (backlog.OldestPendingUtc.HasValue)
|
||||||
|
{
|
||||||
|
snapshot.OldestPendingUtc = Timestamp.FromDateTime(EnsureUtc(backlog.OldestPendingUtc.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.SiteAuditBacklog = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="SiteHealthReport"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process health report; absent wrappers rehydrate as null, not as empty.</returns>
|
||||||
|
public static SiteHealthReport FromDto(SiteHealthReportDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
var statuses = new Dictionary<string, ConnectionHealth>(dto.DataConnectionStatuses.Count);
|
||||||
|
foreach (var (name, health) in dto.DataConnectionStatuses)
|
||||||
|
{
|
||||||
|
statuses[name] = FromDto(health);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolution = new Dictionary<string, TagResolutionStatus>(dto.TagResolutionCounts.Count);
|
||||||
|
foreach (var (name, counts) in dto.TagResolutionCounts)
|
||||||
|
{
|
||||||
|
resolution[name] = new TagResolutionStatus(counts.TotalSubscribed, counts.SuccessfullyResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bufferDepths = new Dictionary<string, int>(dto.StoreAndForwardBufferDepths);
|
||||||
|
|
||||||
|
Dictionary<string, string>? endpoints = null;
|
||||||
|
if (dto.DataConnectionEndpoints is { } endpointWrapper)
|
||||||
|
{
|
||||||
|
endpoints = new Dictionary<string, string>(endpointWrapper.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, TagQualityCounts>? tagQuality = null;
|
||||||
|
if (dto.DataConnectionTagQuality is { } tagQualityWrapper)
|
||||||
|
{
|
||||||
|
tagQuality = new Dictionary<string, TagQualityCounts>(tagQualityWrapper.Entries.Count);
|
||||||
|
foreach (var (name, counts) in tagQualityWrapper.Entries)
|
||||||
|
{
|
||||||
|
tagQuality[name] = new TagQualityCounts(counts.Good, counts.Bad, counts.Uncertain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NodeStatus>? clusterNodes = null;
|
||||||
|
if (dto.ClusterNodes is { } nodeWrapper)
|
||||||
|
{
|
||||||
|
clusterNodes = new List<NodeStatus>(nodeWrapper.Nodes.Count);
|
||||||
|
foreach (var node in nodeWrapper.Nodes)
|
||||||
|
{
|
||||||
|
clusterNodes.Add(new NodeStatus(node.Hostname, node.IsOnline, node.Role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteAuditBacklogSnapshot? backlog = null;
|
||||||
|
if (dto.SiteAuditBacklog is { } snapshot)
|
||||||
|
{
|
||||||
|
backlog = new SiteAuditBacklogSnapshot(
|
||||||
|
PendingCount: snapshot.PendingCount,
|
||||||
|
OldestPendingUtc: snapshot.OldestPendingUtc is null
|
||||||
|
? null
|
||||||
|
: DateTime.SpecifyKind(snapshot.OldestPendingUtc.ToDateTime(), DateTimeKind.Utc),
|
||||||
|
OnDiskBytes: snapshot.OnDiskBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SiteHealthReport(
|
||||||
|
SiteId: dto.SiteId,
|
||||||
|
SequenceNumber: dto.SequenceNumber,
|
||||||
|
ReportTimestamp: dto.ReportTimestamp.ToDateTimeOffset(),
|
||||||
|
DataConnectionStatuses: statuses,
|
||||||
|
TagResolutionCounts: resolution,
|
||||||
|
ScriptErrorCount: dto.ScriptErrorCount,
|
||||||
|
AlarmEvaluationErrorCount: dto.AlarmEvaluationErrorCount,
|
||||||
|
StoreAndForwardBufferDepths: bufferDepths,
|
||||||
|
DeadLetterCount: dto.DeadLetterCount,
|
||||||
|
DeployedInstanceCount: dto.DeployedInstanceCount,
|
||||||
|
EnabledInstanceCount: dto.EnabledInstanceCount,
|
||||||
|
DisabledInstanceCount: dto.DisabledInstanceCount,
|
||||||
|
NodeRole: dto.NodeRole,
|
||||||
|
NodeHostname: dto.NodeHostname,
|
||||||
|
DataConnectionEndpoints: endpoints,
|
||||||
|
DataConnectionTagQuality: tagQuality,
|
||||||
|
ParkedMessageCount: dto.ParkedMessageCount,
|
||||||
|
ClusterNodes: clusterNodes,
|
||||||
|
SiteAuditWriteFailures: dto.SiteAuditWriteFailures,
|
||||||
|
AuditRedactionFailure: dto.AuditRedactionFailure,
|
||||||
|
SiteAuditBacklog: backlog,
|
||||||
|
SiteEventLogWriteFailures: dto.SiteEventLogWriteFailures,
|
||||||
|
OldestParkedMessageAgeSeconds: dto.OldestParkedMessageAgeSeconds)
|
||||||
|
{
|
||||||
|
// Init-only members: SiteHealthReport surfaces the scheduler and LocalDb
|
||||||
|
// gauges as init properties rather than positional parameters, so they
|
||||||
|
// cannot be passed to the constructor above.
|
||||||
|
ScriptQueueDepth = dto.ScriptQueueDepth,
|
||||||
|
ScriptBusyThreads = dto.ScriptBusyThreads,
|
||||||
|
ScriptOldestBusyAgeSeconds = dto.ScriptOldestBusyAgeSeconds,
|
||||||
|
LocalDbReplicationConnected = dto.LocalDbReplicationConnected,
|
||||||
|
LocalDbOplogBacklog = dto.LocalDbOplogBacklog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="SiteHealthReportAck"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The ack to project.</param>
|
||||||
|
/// <returns>The wire-format DTO; a null error collapses to an empty string.</returns>
|
||||||
|
public static SiteHealthReportAckDto ToDto(SiteHealthReportAck msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
return new SiteHealthReportAckDto
|
||||||
|
{
|
||||||
|
SiteId = msg.SiteId,
|
||||||
|
SequenceNumber = msg.SequenceNumber,
|
||||||
|
Accepted = msg.Accepted,
|
||||||
|
Error = msg.Error ?? string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="SiteHealthReportAck"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process ack; an empty error rehydrates as null.</returns>
|
||||||
|
public static SiteHealthReportAck FromDto(SiteHealthReportAckDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new SiteHealthReportAck(
|
||||||
|
SiteId: dto.SiteId,
|
||||||
|
SequenceNumber: dto.SequenceNumber,
|
||||||
|
Accepted: dto.Accepted,
|
||||||
|
Error: NullIfEmpty(dto.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="HeartbeatMessage"/> onto its wire DTO.</summary>
|
||||||
|
/// <param name="msg">The heartbeat to project.</param>
|
||||||
|
/// <returns>The wire-format DTO.</returns>
|
||||||
|
public static HeartbeatDto ToDto(HeartbeatMessage msg)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(msg);
|
||||||
|
|
||||||
|
return new HeartbeatDto
|
||||||
|
{
|
||||||
|
SiteId = msg.SiteId,
|
||||||
|
NodeHostname = msg.NodeHostname,
|
||||||
|
IsActive = msg.IsActive,
|
||||||
|
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="HeartbeatMessage"/> from its wire DTO.</summary>
|
||||||
|
/// <param name="dto">The wire-format DTO to reconstruct.</param>
|
||||||
|
/// <returns>The in-process heartbeat.</returns>
|
||||||
|
public static HeartbeatMessage FromDto(HeartbeatDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new HeartbeatMessage(
|
||||||
|
SiteId: dto.SiteId,
|
||||||
|
NodeHostname: dto.NodeHostname,
|
||||||
|
IsActive: dto.IsActive,
|
||||||
|
Timestamp: dto.Timestamp.ToDateTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="ConnectionHealth"/> onto its wire enum.</summary>
|
||||||
|
/// <param name="health">The connection health to project.</param>
|
||||||
|
/// <returns>The wire enum value. Never <c>ConnectionHealthUnspecified</c>.</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||||
|
/// A <see cref="ConnectionHealth"/> value was added without extending this mapping.
|
||||||
|
/// Throwing beats a silent default: an unmapped state would otherwise be reported as
|
||||||
|
/// whichever value happened to be first.
|
||||||
|
/// </exception>
|
||||||
|
public static ConnectionHealthEnum ToDto(ConnectionHealth health) => health switch
|
||||||
|
{
|
||||||
|
ConnectionHealth.Connected => ConnectionHealthEnum.ConnectionHealthConnected,
|
||||||
|
ConnectionHealth.Disconnected => ConnectionHealthEnum.ConnectionHealthDisconnected,
|
||||||
|
ConnectionHealth.Connecting => ConnectionHealthEnum.ConnectionHealthConnecting,
|
||||||
|
ConnectionHealth.Error => ConnectionHealthEnum.ConnectionHealthError,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unmapped ConnectionHealth value"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Reconstructs a <see cref="ConnectionHealth"/> from its wire enum.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An unspecified or unknown wire value decodes to <see cref="ConnectionHealth.Error"/>,
|
||||||
|
/// never to <see cref="ConnectionHealth.Connected"/>. A newer site sending a value this
|
||||||
|
/// build has never heard of must not have it rendered as "healthy" on the central
|
||||||
|
/// health page — the safe direction for an unknown connection state is "not working".
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="health">The wire enum value to reconstruct.</param>
|
||||||
|
/// <returns>The in-process connection health.</returns>
|
||||||
|
public static ConnectionHealth FromDto(ConnectionHealthEnum health) => health switch
|
||||||
|
{
|
||||||
|
ConnectionHealthEnum.ConnectionHealthConnected => ConnectionHealth.Connected,
|
||||||
|
ConnectionHealthEnum.ConnectionHealthDisconnected => ConnectionHealth.Disconnected,
|
||||||
|
ConnectionHealthEnum.ConnectionHealthConnecting => ConnectionHealth.Connecting,
|
||||||
|
_ => ConnectionHealth.Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GuidToWire(Guid? value) =>
|
||||||
|
value?.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
private static Guid? GuidFromWire(string? value) =>
|
||||||
|
string.IsNullOrEmpty(value) ? null : Guid.Parse(value);
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string? value) =>
|
||||||
|
string.IsNullOrEmpty(value) ? null : value;
|
||||||
|
|
||||||
|
// All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime requires
|
||||||
|
// UTC kind. Specify (never convert) so a value read back from SQLite with Kind=Utc
|
||||||
|
// passes through and a defensively-unspecified one is treated as the UTC it already
|
||||||
|
// is. Mirrors AuditEventDtoMapper/SiteCallDtoMapper.EnsureUtc.
|
||||||
|
private static DateTime EnsureUtc(DateTime value) =>
|
||||||
|
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
@@ -21,15 +21,17 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|||||||
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>.
|
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Two directions are provided. <see cref="FromDto"/> rehydrates the central
|
/// Three directions are provided. <see cref="FromDto"/> rehydrates the central
|
||||||
/// <see cref="SiteCall"/> entity central writes into the <c>SiteCalls</c> table.
|
/// <see cref="SiteCall"/> entity central writes into the <c>SiteCalls</c> table.
|
||||||
/// <see cref="ToDto"/> projects a site-local <see cref="SiteCallOperational"/>
|
/// <see cref="ToDto(SiteCallOperational)"/> projects a site-local
|
||||||
/// onto the wire — used by the Site Call Audit <c>PullSiteCalls</c>
|
/// <see cref="SiteCallOperational"/> onto the wire — used by the Site Call Audit
|
||||||
/// reconciliation handler (the central→site self-heal pull). The
|
/// <c>PullSiteCalls</c> reconciliation handler (the central→site self-heal pull).
|
||||||
/// <see cref="SiteCall"/> entity itself is never mapped back onto the wire:
|
/// <see cref="ToDto(SiteCall)"/> projects the entity form back onto the wire; it
|
||||||
/// sites emit operational state from <see cref="SiteCallOperational"/>, never
|
/// exists for the gRPC central control plane, whose site-side transport receives
|
||||||
/// from the central <see cref="SiteCall"/>, so a <c>SiteCall</c>→DTO method
|
/// an already-decoded <c>IngestCachedTelemetryCommand</c> (which carries
|
||||||
/// would be dead code.
|
/// <see cref="SiteCall"/>, not <see cref="SiteCallOperational"/>) and must
|
||||||
|
/// re-encode it. It was previously documented here as necessarily dead code —
|
||||||
|
/// true only while ClusterClient was the sole path from that command to central.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// String nullability convention: proto3 scalar strings cannot be absent, so the
|
/// String nullability convention: proto3 scalar strings cannot be absent, so the
|
||||||
@@ -120,6 +122,51 @@ public static class SiteCallDtoMapper
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects a <see cref="SiteCall"/> entity onto its wire-format DTO — the
|
||||||
|
/// inverse of <see cref="FromDto"/>, so the pair round-trips exactly.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="SiteCall.IngestedAtUtc"/> is deliberately NOT written: it is
|
||||||
|
/// central-set inside the dual-write transaction and the value carried on the
|
||||||
|
/// wire is informational only (<see cref="FromDto"/> stamps a placeholder that
|
||||||
|
/// the ingest actor overwrites). Every other field survives; null
|
||||||
|
/// <c>SourceNode</c>/<c>LastError</c> collapse to empty strings while the
|
||||||
|
/// nullable <c>HttpStatus</c>/<c>TerminalAtUtc</c> stay unset on the wire.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="siteCall">The central operational-state entity to project.</param>
|
||||||
|
/// <returns>A populated <see cref="SiteCallOperationalDto"/> ready for transmission.</returns>
|
||||||
|
public static SiteCallOperationalDto ToDto(SiteCall siteCall)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(siteCall);
|
||||||
|
|
||||||
|
var dto = new SiteCallOperationalDto
|
||||||
|
{
|
||||||
|
TrackedOperationId = siteCall.TrackedOperationId.ToString(),
|
||||||
|
Channel = siteCall.Channel,
|
||||||
|
Target = siteCall.Target,
|
||||||
|
SourceSite = siteCall.SourceSite,
|
||||||
|
SourceNode = siteCall.SourceNode ?? string.Empty,
|
||||||
|
Status = siteCall.Status,
|
||||||
|
RetryCount = siteCall.RetryCount,
|
||||||
|
LastError = siteCall.LastError ?? string.Empty,
|
||||||
|
CreatedAtUtc = Timestamp.FromDateTime(EnsureUtc(siteCall.CreatedAtUtc)),
|
||||||
|
UpdatedAtUtc = Timestamp.FromDateTime(EnsureUtc(siteCall.UpdatedAtUtc)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (siteCall.HttpStatus.HasValue)
|
||||||
|
{
|
||||||
|
dto.HttpStatus = siteCall.HttpStatus.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteCall.TerminalAtUtc.HasValue)
|
||||||
|
{
|
||||||
|
dto.TerminalAtUtc = Timestamp.FromDateTime(EnsureUtc(siteCall.TerminalAtUtc.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
// All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime
|
// All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime
|
||||||
// requires UTC kind. Specify (never convert) so a row read back from SQLite
|
// requires UTC kind. Specify (never convert) so a row read back from SQLite
|
||||||
// with Kind=Utc passes through and a defensively-unspecified value is
|
// with Kind=Utc passes through and a defensively-unspecified value is
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
option csharp_namespace = "ZB.MOM.WW.ScadaBridge.Communication.Grpc";
|
||||||
|
package scadabridge.centralcontrol.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
import "google/protobuf/wrappers.proto";
|
||||||
|
|
||||||
|
// The two ingest RPCs deliberately REUSE the batch/ack messages already defined
|
||||||
|
// for the site-hosted SiteStreamService rather than redeclaring them. The site
|
||||||
|
// telemetry actor builds AuditEventBatch / CachedTelemetryBatch today and hands
|
||||||
|
// them to ISiteStreamAuditClient; duplicating the shapes here would fork one
|
||||||
|
// wire contract into two that must be kept in lockstep by hand.
|
||||||
|
import "Protos/sitestream.proto";
|
||||||
|
|
||||||
|
// Central-hosted control plane (Phase 1A of the ClusterClient→gRPC migration).
|
||||||
|
//
|
||||||
|
// Direction: SITE is the client, CENTRAL is the server — the inverse of
|
||||||
|
// SiteStreamService, where central dials the site. That asymmetry is deliberate
|
||||||
|
// and mirrors the direction the Akka ClusterClient traffic flows today: these
|
||||||
|
// seven calls are exactly the seven messages SiteCommunicationActor sends to
|
||||||
|
// /user/central-communication.
|
||||||
|
//
|
||||||
|
// Every call is gated by ControlPlaneAuthInterceptor: `authorization: Bearer <psk>`
|
||||||
|
// plus the `x-scadabridge-site` metadata header naming which site's preshared key
|
||||||
|
// central must verify against.
|
||||||
|
service CentralControlService {
|
||||||
|
// Store-and-forward handoff of one notification for central delivery. The
|
||||||
|
// ack is idempotent on notification_id — a duplicate submit after a lost ack
|
||||||
|
// must not produce a second delivery.
|
||||||
|
rpc SubmitNotification(NotificationSubmitDto) returns (NotificationSubmitAckDto);
|
||||||
|
|
||||||
|
// Notify.Status(id) round-trip for a notification that has already left the
|
||||||
|
// site buffer. `found = false` sends the caller back to the site-local buffer
|
||||||
|
// to decide Forwarding vs Unknown.
|
||||||
|
rpc QueryNotificationStatus(NotificationStatusQueryDto) returns (NotificationStatusResponseDto);
|
||||||
|
|
||||||
|
// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
|
||||||
|
// batch a site drains from its SQLite hot path is byte-identical whichever
|
||||||
|
// transport carries it.
|
||||||
|
rpc IngestAuditEvents(sitestream.AuditEventBatch) returns (sitestream.IngestAck);
|
||||||
|
|
||||||
|
// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
|
||||||
|
// operational upsert, written in one central transaction).
|
||||||
|
rpc IngestCachedTelemetry(sitestream.CachedTelemetryBatch) returns (sitestream.IngestAck);
|
||||||
|
|
||||||
|
// Node-startup self-heal: the node's local deployed inventory in, fetch
|
||||||
|
// tokens for whatever it is missing or stale out.
|
||||||
|
rpc ReconcileSite(ReconcileSiteRequestDto) returns (ReconcileSiteResponseDto);
|
||||||
|
|
||||||
|
// Periodic site health report (30 s cadence). The ack makes delivery
|
||||||
|
// observable end-to-end so the sender can restore its per-interval counters
|
||||||
|
// when a report is lost.
|
||||||
|
rpc ReportSiteHealth(SiteHealthReportDto) returns (SiteHealthReportAckDto);
|
||||||
|
|
||||||
|
// Application heartbeat. Returns Empty because the message is
|
||||||
|
// fire-and-forget: nothing on the site consumes a reply, and a failure here
|
||||||
|
// must never surface as a fault on the heartbeat timer path.
|
||||||
|
rpc Heartbeat(HeartbeatDto) returns (google.protobuf.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Notification Outbox (#21)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Site -> Central: submit a buffered notification for central delivery.
|
||||||
|
// Mirrors Commons NotificationSubmit.
|
||||||
|
message NotificationSubmitDto {
|
||||||
|
string notification_id = 1; // GUID string, the idempotency key
|
||||||
|
string list_name = 2;
|
||||||
|
string subject = 3;
|
||||||
|
string body = 4;
|
||||||
|
string source_site_id = 5;
|
||||||
|
string source_instance_id = 6; // empty string represents null
|
||||||
|
string source_script = 7; // empty string represents null
|
||||||
|
google.protobuf.Timestamp site_enqueued_at = 8;
|
||||||
|
string origin_execution_id = 9; // GUID string; empty represents null
|
||||||
|
string origin_parent_execution_id = 10; // GUID string; empty represents null
|
||||||
|
string source_node = 11; // empty string represents null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central -> Site: ack sent after the Notifications row is persisted.
|
||||||
|
message NotificationSubmitAckDto {
|
||||||
|
string notification_id = 1;
|
||||||
|
bool accepted = 2;
|
||||||
|
string error = 3; // empty string represents null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site -> Central: Notify.Status(id) lookup against the central outbox.
|
||||||
|
message NotificationStatusQueryDto {
|
||||||
|
string correlation_id = 1;
|
||||||
|
string notification_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central -> Site: current central delivery state for a queried notification.
|
||||||
|
message NotificationStatusResponseDto {
|
||||||
|
string correlation_id = 1;
|
||||||
|
bool found = 2;
|
||||||
|
string status = 3;
|
||||||
|
int32 retry_count = 4;
|
||||||
|
string last_error = 5; // empty string represents null
|
||||||
|
google.protobuf.Timestamp delivered_at = 6; // absent when null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Startup reconciliation (Deployment Manager)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Site -> Central: the node's local deployed inventory at startup.
|
||||||
|
message ReconcileSiteRequestDto {
|
||||||
|
string site_identifier = 1;
|
||||||
|
string node_id = 2;
|
||||||
|
// Instance unique name -> revision hash of the config the node currently holds.
|
||||||
|
map<string, string> local_name_to_revision_hash = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central -> Site: the gap the node must (re)fetch, plus orphans to log.
|
||||||
|
message ReconcileSiteResponseDto {
|
||||||
|
repeated ReconcileGapItemDto gap = 1;
|
||||||
|
repeated string orphan_names = 2;
|
||||||
|
string central_fetch_base_url = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One instance the node must (re)fetch, with a freshly-minted short-TTL token.
|
||||||
|
message ReconcileGapItemDto {
|
||||||
|
string instance_unique_name = 1;
|
||||||
|
string deployment_id = 2;
|
||||||
|
string revision_hash = 3;
|
||||||
|
bool is_enabled = 4;
|
||||||
|
string fetch_token = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health Monitoring (#11)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Wire form of the Commons ConnectionHealth enum.
|
||||||
|
//
|
||||||
|
// CONNECTION_HEALTH_UNSPECIFIED exists only to keep the proto3 zero value from
|
||||||
|
// meaning something. Mapping Connected onto 0 would make an absent/garbled
|
||||||
|
// value decode as "healthy", which is precisely the wrong direction to fail;
|
||||||
|
// the mapper decodes UNSPECIFIED as Error instead and never emits it.
|
||||||
|
enum ConnectionHealthEnum {
|
||||||
|
CONNECTION_HEALTH_UNSPECIFIED = 0;
|
||||||
|
CONNECTION_HEALTH_CONNECTED = 1;
|
||||||
|
CONNECTION_HEALTH_DISCONNECTED = 2;
|
||||||
|
CONNECTION_HEALTH_CONNECTING = 3;
|
||||||
|
CONNECTION_HEALTH_ERROR = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TagResolutionStatusDto {
|
||||||
|
int32 total_subscribed = 1;
|
||||||
|
int32 successfully_resolved = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TagQualityCountsDto {
|
||||||
|
int32 good = 1;
|
||||||
|
int32 bad = 2;
|
||||||
|
int32 uncertain = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NodeStatusDto {
|
||||||
|
string hostname = 1;
|
||||||
|
bool is_online = 2;
|
||||||
|
string role = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point-in-time snapshot of the site-local SQLite audit queue.
|
||||||
|
message SiteAuditBacklogSnapshotDto {
|
||||||
|
int32 pending_count = 1;
|
||||||
|
google.protobuf.Timestamp oldest_pending_utc = 2; // absent when the queue is empty
|
||||||
|
int64 on_disk_bytes = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three collection wrappers below exist so null and empty stay
|
||||||
|
// distinguishable. proto3 cannot express presence on a `repeated` or `map`
|
||||||
|
// field — an unset one and an empty one are the same bytes — but the
|
||||||
|
// corresponding SiteHealthReport members are genuinely nullable
|
||||||
|
// (SiteHealthCollector emits `ClusterNodes: _clusterNodes?.ToList()`), and the
|
||||||
|
// central health surface reads null as "this producer doesn't report the
|
||||||
|
// signal" rather than "the signal is empty". Wrapping in a message restores
|
||||||
|
// message presence and makes the distinction survive the round-trip.
|
||||||
|
|
||||||
|
message ConnectionEndpointMapDto {
|
||||||
|
map<string, string> entries = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TagQualityMapDto {
|
||||||
|
map<string, TagQualityCountsDto> entries = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NodeStatusListDto {
|
||||||
|
repeated NodeStatusDto nodes = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site -> Central: periodic site health report. Mirrors Commons SiteHealthReport.
|
||||||
|
// Additive-only evolution: field numbers are never reused.
|
||||||
|
message SiteHealthReportDto {
|
||||||
|
string site_id = 1;
|
||||||
|
int64 sequence_number = 2;
|
||||||
|
google.protobuf.Timestamp report_timestamp = 3;
|
||||||
|
map<string, ConnectionHealthEnum> data_connection_statuses = 4;
|
||||||
|
map<string, TagResolutionStatusDto> tag_resolution_counts = 5;
|
||||||
|
int32 script_error_count = 6;
|
||||||
|
int32 alarm_evaluation_error_count = 7;
|
||||||
|
map<string, int32> store_and_forward_buffer_depths = 8;
|
||||||
|
int32 dead_letter_count = 9;
|
||||||
|
int32 deployed_instance_count = 10;
|
||||||
|
int32 enabled_instance_count = 11;
|
||||||
|
int32 disabled_instance_count = 12;
|
||||||
|
string node_role = 13;
|
||||||
|
string node_hostname = 14;
|
||||||
|
ConnectionEndpointMapDto data_connection_endpoints = 15; // absent when null
|
||||||
|
TagQualityMapDto data_connection_tag_quality = 16; // absent when null
|
||||||
|
int32 parked_message_count = 17;
|
||||||
|
NodeStatusListDto cluster_nodes = 18; // absent when null
|
||||||
|
int32 site_audit_write_failures = 19;
|
||||||
|
int32 audit_redaction_failure = 20;
|
||||||
|
SiteAuditBacklogSnapshotDto site_audit_backlog = 21; // absent when no data yet
|
||||||
|
int64 site_event_log_write_failures = 22;
|
||||||
|
google.protobuf.DoubleValue oldest_parked_message_age_seconds = 23; // absent when nothing parked
|
||||||
|
int32 script_queue_depth = 24;
|
||||||
|
int32 script_busy_threads = 25;
|
||||||
|
google.protobuf.DoubleValue script_oldest_busy_age_seconds = 26; // absent when the pool is idle
|
||||||
|
// Nullable on purpose: absent means "replication not wired on this node",
|
||||||
|
// which is NOT the same as false ("wired but currently disconnected").
|
||||||
|
google.protobuf.BoolValue local_db_replication_connected = 27;
|
||||||
|
// Absent means UNKNOWN, never zero — a failed backlog read rendered as 0
|
||||||
|
// would report a broken replication pair as perfectly healthy.
|
||||||
|
google.protobuf.Int64Value local_db_oplog_backlog = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central -> Site: health report ack, so a lost report is observable.
|
||||||
|
message SiteHealthReportAckDto {
|
||||||
|
string site_id = 1;
|
||||||
|
int64 sequence_number = 2;
|
||||||
|
bool accepted = 3;
|
||||||
|
string error = 4; // empty string represents null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site -> Central: application heartbeat (fire-and-forget; reply is Empty).
|
||||||
|
message HeartbeatDto {
|
||||||
|
string site_id = 1;
|
||||||
|
string node_hostname = 2;
|
||||||
|
bool is_active = 3;
|
||||||
|
google.protobuf.Timestamp timestamp = 4;
|
||||||
|
}
|
||||||
@@ -32,20 +32,30 @@
|
|||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- gRPC proto generation. The compiled C# is checked in under
|
<!-- gRPC proto generation. The compiled C# is checked in — SiteStreamGrpc/
|
||||||
SiteStreamGrpc/ (Sitestream.cs + SitestreamGrpc.cs) because protoc
|
(Sitestream.cs + SitestreamGrpc.cs) for Protos/sitestream.proto, and
|
||||||
segfaults inside our linux_arm64 Docker build image. To regenerate
|
CentralControlGrpc/ (CentralControl.cs + CentralControlGrpc.cs) for
|
||||||
after schema changes:
|
Protos/central_control.proto — because protoc segfaults inside our
|
||||||
1. Temporarily uncomment the Protobuf ItemGroup below.
|
linux_arm64 Docker build image. To regenerate after schema changes run
|
||||||
2. Delete SiteStreamGrpc/*.cs.
|
`docker/regen-proto.sh [sitestream|centralcontrol|all]`, which does all
|
||||||
|
of the following and always leaves this file as it found it:
|
||||||
|
1. Temporarily uncomment the Protobuf ItemGroup below (just the line
|
||||||
|
for the proto you changed — the other file's checked-in C# is
|
||||||
|
already compiled, so enabling both at once duplicates types).
|
||||||
|
2. Delete the matching checked-in *.cs.
|
||||||
3. `dotnet build` (on macOS) — Grpc.Tools writes fresh files to obj/.
|
3. `dotnet build` (on macOS) — Grpc.Tools writes fresh files to obj/.
|
||||||
4. Copy obj/Debug/net10.0/Protos/*.cs into SiteStreamGrpc/.
|
4. Copy obj/Debug/net10.0/Protos/*.cs into the matching folder.
|
||||||
5. Re-comment the ItemGroup.
|
5. Re-comment the ItemGroup.
|
||||||
Eventually we should switch the Docker build image to one with a
|
central_control.proto imports sitestream.proto, so protoc resolves it
|
||||||
working protoc on arm64. -->
|
from the project-relative path without sitestream.proto needing its own
|
||||||
|
Protobuf item.
|
||||||
|
An ACTIVE Protobuf item must never be committed — it breaks the Docker
|
||||||
|
image build. Eventually we should switch the Docker build image to one
|
||||||
|
with a working protoc on arm64. -->
|
||||||
<!--
|
<!--
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />
|
<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />
|
||||||
|
<Protobuf Include="Protos\central_control.proto" GrpcServices="Both" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,643 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip golden tests for <see cref="CentralControlDtoMapper"/> — the DTO bridge
|
||||||
|
/// for the seven <c>CentralControlService</c> RPCs (Phase 1A of the ClusterClient→gRPC
|
||||||
|
/// migration).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every message gets a pair: a FULLY-POPULATED case that proves no field is dropped,
|
||||||
|
/// and a MINIMAL case that proves every nullable/optional/empty-collection member comes
|
||||||
|
/// back as null-or-empty rather than as a zero-valued stand-in. The minimal cases are
|
||||||
|
/// the ones that matter: a per-field unit test happily passes while a whole optional
|
||||||
|
/// branch is silently never written, which is exactly the class of bug the round-trip
|
||||||
|
/// guard in PLAN-05 T8 caught five times over.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// When a round-trip here fails, the mapper is wrong — not the test.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class CentralControlDtoMapperTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset SampleInstant =
|
||||||
|
new(2026, 7, 22, 9, 30, 15, 250, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// NotificationSubmit / Ack
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationSubmit_RoundTrip_FullyPopulated_PreservesEveryField()
|
||||||
|
{
|
||||||
|
var original = new NotificationSubmit(
|
||||||
|
NotificationId: Guid.NewGuid().ToString(),
|
||||||
|
ListName: "plant-ops",
|
||||||
|
Subject: "Tank 4 overfill",
|
||||||
|
Body: "Level exceeded 95% for 5 minutes.",
|
||||||
|
SourceSiteId: "site-a",
|
||||||
|
SourceInstanceId: "Tank04",
|
||||||
|
SourceScript: "OnLevelHigh",
|
||||||
|
SiteEnqueuedAt: SampleInstant,
|
||||||
|
OriginExecutionId: Guid.NewGuid(),
|
||||||
|
OriginParentExecutionId: Guid.NewGuid(),
|
||||||
|
SourceNode: "node-b");
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
// Every member is a scalar, so record value-equality is a true deep compare.
|
||||||
|
Assert.Equal(original, roundTripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationSubmit_RoundTrip_AllOptionalsNull_StayNull()
|
||||||
|
{
|
||||||
|
var original = new NotificationSubmit(
|
||||||
|
NotificationId: Guid.NewGuid().ToString(),
|
||||||
|
ListName: "plant-ops",
|
||||||
|
Subject: "s",
|
||||||
|
Body: "b",
|
||||||
|
SourceSiteId: "site-a",
|
||||||
|
SourceInstanceId: null,
|
||||||
|
SourceScript: null,
|
||||||
|
SiteEnqueuedAt: SampleInstant,
|
||||||
|
OriginExecutionId: null,
|
||||||
|
OriginParentExecutionId: null,
|
||||||
|
SourceNode: null);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(original, roundTripped);
|
||||||
|
Assert.Null(roundTripped.SourceInstanceId);
|
||||||
|
Assert.Null(roundTripped.SourceScript);
|
||||||
|
Assert.Null(roundTripped.OriginExecutionId);
|
||||||
|
Assert.Null(roundTripped.OriginParentExecutionId);
|
||||||
|
Assert.Null(roundTripped.SourceNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationSubmit_NonUtcOffset_NormalizesToTheSameInstant()
|
||||||
|
{
|
||||||
|
// Documented contract: a protobuf Timestamp is an instant, so the offset
|
||||||
|
// component of a DateTimeOffset does not survive. Every producer stamps UTC
|
||||||
|
// (Notify.Send uses DateTimeOffset.UtcNow), so the instant is what matters.
|
||||||
|
var melbourne = new DateTimeOffset(2026, 7, 22, 19, 30, 15, TimeSpan.FromHours(10));
|
||||||
|
|
||||||
|
var original = new NotificationSubmit(
|
||||||
|
"id", "list", "s", "b", "site-a", null, null, melbourne);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(melbourne.UtcDateTime, roundTripped.SiteEnqueuedAt.UtcDateTime);
|
||||||
|
Assert.Equal(TimeSpan.Zero, roundTripped.SiteEnqueuedAt.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationSubmitAck_RoundTrip_Accepted_And_Rejected()
|
||||||
|
{
|
||||||
|
var accepted = new NotificationSubmitAck("n1", Accepted: true, Error: null);
|
||||||
|
var rejected = new NotificationSubmitAck("n2", Accepted: false, Error: "list not found");
|
||||||
|
|
||||||
|
Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))));
|
||||||
|
Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected))));
|
||||||
|
Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// NotificationStatusQuery / Response
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationStatusQuery_RoundTrip_PreservesEveryField()
|
||||||
|
{
|
||||||
|
var original = new NotificationStatusQuery("corr-1", Guid.NewGuid().ToString());
|
||||||
|
|
||||||
|
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationStatusResponse_RoundTrip_FullyPopulated_PreservesEveryField()
|
||||||
|
{
|
||||||
|
var original = new NotificationStatusResponse(
|
||||||
|
CorrelationId: "corr-1",
|
||||||
|
Found: true,
|
||||||
|
Status: "Delivered",
|
||||||
|
RetryCount: 3,
|
||||||
|
LastError: "smtp 421",
|
||||||
|
DeliveredAt: SampleInstant);
|
||||||
|
|
||||||
|
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationStatusResponse_RoundTrip_NotFound_LeavesOptionalsNull()
|
||||||
|
{
|
||||||
|
var original = new NotificationStatusResponse(
|
||||||
|
CorrelationId: "corr-1",
|
||||||
|
Found: false,
|
||||||
|
Status: "Unknown",
|
||||||
|
RetryCount: 0,
|
||||||
|
LastError: null,
|
||||||
|
DeliveredAt: null);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(original, roundTripped);
|
||||||
|
Assert.Null(roundTripped.LastError);
|
||||||
|
Assert.Null(roundTripped.DeliveredAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Audit ingest envelopes (rows delegate to AuditEventDtoMapper / SiteCallDtoMapper)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAuditEventsCommand_RoundTrip_PreservesOrderAndRows()
|
||||||
|
{
|
||||||
|
var first = NewAuditEvent(sourceScript: "OnDemand");
|
||||||
|
var second = NewAuditEvent(sourceScript: "OnTrigger");
|
||||||
|
var original = new IngestAuditEventsCommand([first, second]);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(2, roundTripped.Events.Count);
|
||||||
|
Assert.Equal(first.AsRow().EventId, roundTripped.Events[0].AsRow().EventId);
|
||||||
|
Assert.Equal("OnDemand", roundTripped.Events[0].AsRow().SourceScript);
|
||||||
|
Assert.Equal(second.AsRow().EventId, roundTripped.Events[1].AsRow().EventId);
|
||||||
|
Assert.Equal("OnTrigger", roundTripped.Events[1].AsRow().SourceScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAuditEventsCommand_RoundTrip_EmptyBatch_StaysEmpty()
|
||||||
|
{
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(
|
||||||
|
Wire(CentralControlDtoMapper.ToDto(new IngestAuditEventsCommand([]))));
|
||||||
|
|
||||||
|
Assert.Empty(roundTripped.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestCachedTelemetryCommand_RoundTrip_PreservesBothHalvesOfEachPacket()
|
||||||
|
{
|
||||||
|
var audit = NewAuditEvent(sourceScript: "OnDemand");
|
||||||
|
var siteCall = NewSiteCall();
|
||||||
|
var original = new IngestCachedTelemetryCommand([new CachedTelemetryEntry(audit, siteCall)]);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
var entry = Assert.Single(roundTripped.Entries);
|
||||||
|
Assert.Equal(audit.AsRow().EventId, entry.Audit.AsRow().EventId);
|
||||||
|
|
||||||
|
// IngestedAtUtc is central-set inside the dual-write transaction and is
|
||||||
|
// deliberately off the wire, so it is the one member excluded from the compare.
|
||||||
|
Assert.Equal(siteCall with { IngestedAtUtc = default }, entry.SiteCall with { IngestedAtUtc = default });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestCachedTelemetryCommand_RoundTrip_NullableSiteCallFields_StayNull()
|
||||||
|
{
|
||||||
|
var siteCall = NewSiteCall() with
|
||||||
|
{
|
||||||
|
SourceNode = null,
|
||||||
|
LastError = null,
|
||||||
|
HttpStatus = null,
|
||||||
|
TerminalAtUtc = null,
|
||||||
|
};
|
||||||
|
var original = new IngestCachedTelemetryCommand(
|
||||||
|
[new CachedTelemetryEntry(NewAuditEvent(), siteCall)]);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
var entry = Assert.Single(roundTripped.Entries);
|
||||||
|
Assert.Null(entry.SiteCall.SourceNode);
|
||||||
|
Assert.Null(entry.SiteCall.LastError);
|
||||||
|
Assert.Null(entry.SiteCall.HttpStatus);
|
||||||
|
Assert.Null(entry.SiteCall.TerminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestCachedTelemetryCommand_RoundTrip_EmptyBatch_StaysEmpty()
|
||||||
|
{
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(
|
||||||
|
Wire(CentralControlDtoMapper.ToDto(new IngestCachedTelemetryCommand([]))));
|
||||||
|
|
||||||
|
Assert.Empty(roundTripped.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAck_RoundTrip_PreservesIdsAndOrder()
|
||||||
|
{
|
||||||
|
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromIngestAck(
|
||||||
|
Wire(CentralControlDtoMapper.ToIngestAck(ids)));
|
||||||
|
|
||||||
|
Assert.Equal(ids, roundTripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAck_RoundTrip_NothingAccepted_StaysEmpty()
|
||||||
|
{
|
||||||
|
// An empty ack is meaningful — "zero rows persisted" leaves the site's rows
|
||||||
|
// Pending — so it must not be indistinguishable from a dropped field.
|
||||||
|
Assert.Empty(CentralControlDtoMapper.FromIngestAck(
|
||||||
|
Wire(CentralControlDtoMapper.ToIngestAck([]))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Startup reconciliation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconcileSiteRequest_RoundTrip_PreservesInventoryMap()
|
||||||
|
{
|
||||||
|
var original = new ReconcileSiteRequest(
|
||||||
|
SiteIdentifier: "site-a",
|
||||||
|
NodeId: "node-a",
|
||||||
|
LocalNameToRevisionHash: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Plant.Tank04"] = "hash-1",
|
||||||
|
["Plant.Pump01"] = "hash-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(original.SiteIdentifier, roundTripped.SiteIdentifier);
|
||||||
|
Assert.Equal(original.NodeId, roundTripped.NodeId);
|
||||||
|
Assert.Equal(
|
||||||
|
original.LocalNameToRevisionHash.OrderBy(kv => kv.Key),
|
||||||
|
roundTripped.LocalNameToRevisionHash.OrderBy(kv => kv.Key));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconcileSiteRequest_RoundTrip_EmptyInventory_StaysEmpty()
|
||||||
|
{
|
||||||
|
// A node with nothing deployed is the normal first-boot case, and central
|
||||||
|
// must see an empty inventory rather than a missing one.
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(
|
||||||
|
new ReconcileSiteRequest("site-a", "node-a", new Dictionary<string, string>()))));
|
||||||
|
|
||||||
|
Assert.Empty(roundTripped.LocalNameToRevisionHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconcileSiteResponse_RoundTrip_PreservesGapOrphansAndUrl()
|
||||||
|
{
|
||||||
|
var original = new ReconcileSiteResponse(
|
||||||
|
Gap:
|
||||||
|
[
|
||||||
|
new ReconcileGapItem("Plant.Tank04", "dep-1", "hash-1", IsEnabled: true, "token-1"),
|
||||||
|
new ReconcileGapItem("Plant.Pump01", "dep-2", "hash-2", IsEnabled: false, "token-2"),
|
||||||
|
],
|
||||||
|
OrphanNames: ["Plant.Retired01", "Plant.Retired02"],
|
||||||
|
CentralFetchBaseUrl: "http://scadabridge-central-a:5000");
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(original.Gap, roundTripped.Gap);
|
||||||
|
Assert.Equal(original.OrphanNames, roundTripped.OrphanNames);
|
||||||
|
Assert.Equal(original.CentralFetchBaseUrl, roundTripped.CentralFetchBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconcileSiteResponse_RoundTrip_NoGap_StaysEmpty()
|
||||||
|
{
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(
|
||||||
|
new ReconcileSiteResponse([], [], "http://central:5000"))));
|
||||||
|
|
||||||
|
Assert.Empty(roundTripped.Gap);
|
||||||
|
Assert.Empty(roundTripped.OrphanNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Site health
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteHealthReport_RoundTrip_FullyPopulated_PreservesEveryField()
|
||||||
|
{
|
||||||
|
var original = FullyPopulatedHealthReport();
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
AssertHealthReportsEqual(original, roundTripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteHealthReport_RoundTrip_MinimalReport_LeavesEveryOptionalNullOrEmpty()
|
||||||
|
{
|
||||||
|
// The shape a producer emits before any reporter has run: no endpoints map, no
|
||||||
|
// tag-quality map, no cluster-node list, no audit backlog, no nullable gauges.
|
||||||
|
var original = new SiteHealthReport(
|
||||||
|
SiteId: "site-a",
|
||||||
|
SequenceNumber: 1,
|
||||||
|
ReportTimestamp: SampleInstant,
|
||||||
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
||||||
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||||
|
ScriptErrorCount: 0,
|
||||||
|
AlarmEvaluationErrorCount: 0,
|
||||||
|
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||||
|
DeadLetterCount: 0,
|
||||||
|
DeployedInstanceCount: 0,
|
||||||
|
EnabledInstanceCount: 0,
|
||||||
|
DisabledInstanceCount: 0);
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
AssertHealthReportsEqual(original, roundTripped);
|
||||||
|
|
||||||
|
Assert.Empty(roundTripped.DataConnectionStatuses);
|
||||||
|
Assert.Empty(roundTripped.TagResolutionCounts);
|
||||||
|
Assert.Empty(roundTripped.StoreAndForwardBufferDepths);
|
||||||
|
Assert.Null(roundTripped.DataConnectionEndpoints);
|
||||||
|
Assert.Null(roundTripped.DataConnectionTagQuality);
|
||||||
|
Assert.Null(roundTripped.ClusterNodes);
|
||||||
|
Assert.Null(roundTripped.SiteAuditBacklog);
|
||||||
|
Assert.Null(roundTripped.OldestParkedMessageAgeSeconds);
|
||||||
|
Assert.Null(roundTripped.ScriptOldestBusyAgeSeconds);
|
||||||
|
Assert.Null(roundTripped.LocalDbReplicationConnected);
|
||||||
|
Assert.Null(roundTripped.LocalDbOplogBacklog);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteHealthReport_RoundTrip_EmptyNullableCollections_StayEmptyNotNull()
|
||||||
|
{
|
||||||
|
// The distinction the three wrapper messages exist for. Null means "this node
|
||||||
|
// does not report the signal"; empty means "it reports it, and there is
|
||||||
|
// nothing in it". proto3 cannot express presence on repeated/map fields, so
|
||||||
|
// collapsing one into the other here would be invisible until an operator
|
||||||
|
// misread the health page.
|
||||||
|
var original = FullyPopulatedHealthReport() with
|
||||||
|
{
|
||||||
|
DataConnectionEndpoints = new Dictionary<string, string>(),
|
||||||
|
DataConnectionTagQuality = new Dictionary<string, TagQualityCounts>(),
|
||||||
|
ClusterNodes = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.NotNull(roundTripped.DataConnectionEndpoints);
|
||||||
|
Assert.Empty(roundTripped.DataConnectionEndpoints);
|
||||||
|
Assert.NotNull(roundTripped.DataConnectionTagQuality);
|
||||||
|
Assert.Empty(roundTripped.DataConnectionTagQuality);
|
||||||
|
Assert.NotNull(roundTripped.ClusterNodes);
|
||||||
|
Assert.Empty(roundTripped.ClusterNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteHealthReport_RoundTrip_FalseAndZeroGauges_StayFalseAndZero()
|
||||||
|
{
|
||||||
|
// The mirror of the null case: LocalDbReplicationConnected=false ("configured
|
||||||
|
// but disconnected") and LocalDbOplogBacklog=0 ("healthy, nothing queued") are
|
||||||
|
// real values that must not decay into null on the wire.
|
||||||
|
var original = FullyPopulatedHealthReport() with
|
||||||
|
{
|
||||||
|
LocalDbReplicationConnected = false,
|
||||||
|
LocalDbOplogBacklog = 0,
|
||||||
|
OldestParkedMessageAgeSeconds = 0d,
|
||||||
|
ScriptOldestBusyAgeSeconds = 0d,
|
||||||
|
};
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.False(roundTripped.LocalDbReplicationConnected);
|
||||||
|
Assert.Equal(0L, roundTripped.LocalDbOplogBacklog);
|
||||||
|
Assert.Equal(0d, roundTripped.OldestParkedMessageAgeSeconds);
|
||||||
|
Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending()
|
||||||
|
{
|
||||||
|
var original = FullyPopulatedHealthReport() with
|
||||||
|
{
|
||||||
|
SiteAuditBacklog = new SiteAuditBacklogSnapshot(0, null, 4096),
|
||||||
|
};
|
||||||
|
|
||||||
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
||||||
|
|
||||||
|
Assert.Equal(new SiteAuditBacklogSnapshot(0, null, 4096), roundTripped.SiteAuditBacklog);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteHealthReportAck_RoundTrip_Accepted_And_Rejected()
|
||||||
|
{
|
||||||
|
var accepted = new SiteHealthReportAck("site-a", 42, Accepted: true);
|
||||||
|
var rejected = new SiteHealthReportAck("site-a", 43, Accepted: false, Error: "aggregator down");
|
||||||
|
|
||||||
|
Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))));
|
||||||
|
Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected))));
|
||||||
|
Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Heartbeat + ConnectionHealth enum
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void HeartbeatMessage_RoundTrip_PreservesEveryField(bool isActive)
|
||||||
|
{
|
||||||
|
var original = new HeartbeatMessage("site-a", "scadabridge-site-a-node-b", isActive, SampleInstant);
|
||||||
|
|
||||||
|
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ConnectionHealth.Connected)]
|
||||||
|
[InlineData(ConnectionHealth.Disconnected)]
|
||||||
|
[InlineData(ConnectionHealth.Connecting)]
|
||||||
|
[InlineData(ConnectionHealth.Error)]
|
||||||
|
public void ConnectionHealth_RoundTrip_EveryValueSurvives(ConnectionHealth health)
|
||||||
|
{
|
||||||
|
Assert.Equal(health, CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(health)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionHealth_Unspecified_DecodesToErrorNotConnected()
|
||||||
|
{
|
||||||
|
// Fail-safe direction: a wire value this build does not recognise must never
|
||||||
|
// render as "healthy" on the central health page.
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionHealth.Error,
|
||||||
|
CentralControlDtoMapper.FromDto(ConnectionHealthEnum.ConnectionHealthUnspecified));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionHealth_EveryEnumMemberIsMapped()
|
||||||
|
{
|
||||||
|
// Guards the ArgumentOutOfRangeException path: adding a ConnectionHealth value
|
||||||
|
// without extending the mapper should fail here, not silently on a rig.
|
||||||
|
foreach (var health in Enum.GetValues<ConnectionHealth>())
|
||||||
|
{
|
||||||
|
var wire = CentralControlDtoMapper.ToDto(health);
|
||||||
|
Assert.NotEqual(ConnectionHealthEnum.ConnectionHealthUnspecified, wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static SiteHealthReport FullyPopulatedHealthReport() =>
|
||||||
|
new(
|
||||||
|
SiteId: "site-a",
|
||||||
|
SequenceNumber: 987654321L,
|
||||||
|
ReportTimestamp: SampleInstant,
|
||||||
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
|
||||||
|
{
|
||||||
|
["opc-main"] = ConnectionHealth.Connected,
|
||||||
|
["mx-gateway"] = ConnectionHealth.Error,
|
||||||
|
["opc-backup"] = ConnectionHealth.Connecting,
|
||||||
|
["legacy"] = ConnectionHealth.Disconnected,
|
||||||
|
},
|
||||||
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>
|
||||||
|
{
|
||||||
|
["opc-main"] = new(TotalSubscribed: 120, SuccessfullyResolved: 118),
|
||||||
|
},
|
||||||
|
ScriptErrorCount: 4,
|
||||||
|
AlarmEvaluationErrorCount: 2,
|
||||||
|
StoreAndForwardBufferDepths: new Dictionary<string, int>
|
||||||
|
{
|
||||||
|
["erp"] = 17,
|
||||||
|
["mes"] = 0,
|
||||||
|
},
|
||||||
|
DeadLetterCount: 5,
|
||||||
|
DeployedInstanceCount: 30,
|
||||||
|
EnabledInstanceCount: 28,
|
||||||
|
DisabledInstanceCount: 2,
|
||||||
|
NodeRole: "Active",
|
||||||
|
NodeHostname: "scadabridge-site-a-node-a",
|
||||||
|
DataConnectionEndpoints: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["opc-main"] = "opc.tcp://opcua:4840",
|
||||||
|
},
|
||||||
|
DataConnectionTagQuality: new Dictionary<string, TagQualityCounts>
|
||||||
|
{
|
||||||
|
["opc-main"] = new(Good: 100, Bad: 3, Uncertain: 15),
|
||||||
|
},
|
||||||
|
ParkedMessageCount: 6,
|
||||||
|
ClusterNodes:
|
||||||
|
[
|
||||||
|
new NodeStatus("scadabridge-site-a-node-a", IsOnline: true, "Active"),
|
||||||
|
new NodeStatus("scadabridge-site-a-node-b", IsOnline: false, "Standby"),
|
||||||
|
],
|
||||||
|
SiteAuditWriteFailures: 7,
|
||||||
|
AuditRedactionFailure: 8,
|
||||||
|
SiteAuditBacklog: new SiteAuditBacklogSnapshot(
|
||||||
|
PendingCount: 42,
|
||||||
|
OldestPendingUtc: new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
OnDiskBytes: 1_234_567L),
|
||||||
|
SiteEventLogWriteFailures: 9L,
|
||||||
|
OldestParkedMessageAgeSeconds: 3600.5d)
|
||||||
|
{
|
||||||
|
ScriptQueueDepth = 11,
|
||||||
|
ScriptBusyThreads = 3,
|
||||||
|
ScriptOldestBusyAgeSeconds = 12.25d,
|
||||||
|
LocalDbReplicationConnected = true,
|
||||||
|
LocalDbOplogBacklog = 250L,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares two reports member by member. <see cref="SiteHealthReport"/> is a record,
|
||||||
|
/// but its dictionary/list members compare by reference under record equality, so
|
||||||
|
/// <c>Assert.Equal(a, b)</c> would pass trivially for the scalars and never look
|
||||||
|
/// inside the collections — the exact blind spot these goldens exist to close.
|
||||||
|
/// </summary>
|
||||||
|
private static void AssertHealthReportsEqual(SiteHealthReport expected, SiteHealthReport actual)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected.SiteId, actual.SiteId);
|
||||||
|
Assert.Equal(expected.SequenceNumber, actual.SequenceNumber);
|
||||||
|
Assert.Equal(expected.ReportTimestamp, actual.ReportTimestamp);
|
||||||
|
Assert.Equal(
|
||||||
|
expected.DataConnectionStatuses.OrderBy(kv => kv.Key),
|
||||||
|
actual.DataConnectionStatuses.OrderBy(kv => kv.Key));
|
||||||
|
Assert.Equal(
|
||||||
|
expected.TagResolutionCounts.OrderBy(kv => kv.Key),
|
||||||
|
actual.TagResolutionCounts.OrderBy(kv => kv.Key));
|
||||||
|
Assert.Equal(expected.ScriptErrorCount, actual.ScriptErrorCount);
|
||||||
|
Assert.Equal(expected.AlarmEvaluationErrorCount, actual.AlarmEvaluationErrorCount);
|
||||||
|
Assert.Equal(
|
||||||
|
expected.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key),
|
||||||
|
actual.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key));
|
||||||
|
Assert.Equal(expected.DeadLetterCount, actual.DeadLetterCount);
|
||||||
|
Assert.Equal(expected.DeployedInstanceCount, actual.DeployedInstanceCount);
|
||||||
|
Assert.Equal(expected.EnabledInstanceCount, actual.EnabledInstanceCount);
|
||||||
|
Assert.Equal(expected.DisabledInstanceCount, actual.DisabledInstanceCount);
|
||||||
|
Assert.Equal(expected.NodeRole, actual.NodeRole);
|
||||||
|
Assert.Equal(expected.NodeHostname, actual.NodeHostname);
|
||||||
|
Assert.Equal(
|
||||||
|
expected.DataConnectionEndpoints?.OrderBy(kv => kv.Key),
|
||||||
|
actual.DataConnectionEndpoints?.OrderBy(kv => kv.Key));
|
||||||
|
Assert.Equal(
|
||||||
|
expected.DataConnectionTagQuality?.OrderBy(kv => kv.Key),
|
||||||
|
actual.DataConnectionTagQuality?.OrderBy(kv => kv.Key));
|
||||||
|
Assert.Equal(expected.ParkedMessageCount, actual.ParkedMessageCount);
|
||||||
|
Assert.Equal(expected.ClusterNodes, actual.ClusterNodes);
|
||||||
|
Assert.Equal(expected.SiteAuditWriteFailures, actual.SiteAuditWriteFailures);
|
||||||
|
Assert.Equal(expected.AuditRedactionFailure, actual.AuditRedactionFailure);
|
||||||
|
Assert.Equal(expected.SiteAuditBacklog, actual.SiteAuditBacklog);
|
||||||
|
Assert.Equal(expected.SiteEventLogWriteFailures, actual.SiteEventLogWriteFailures);
|
||||||
|
Assert.Equal(expected.OldestParkedMessageAgeSeconds, actual.OldestParkedMessageAgeSeconds);
|
||||||
|
Assert.Equal(expected.ScriptQueueDepth, actual.ScriptQueueDepth);
|
||||||
|
Assert.Equal(expected.ScriptBusyThreads, actual.ScriptBusyThreads);
|
||||||
|
Assert.Equal(expected.ScriptOldestBusyAgeSeconds, actual.ScriptOldestBusyAgeSeconds);
|
||||||
|
Assert.Equal(expected.LocalDbReplicationConnected, actual.LocalDbReplicationConnected);
|
||||||
|
Assert.Equal(expected.LocalDbOplogBacklog, actual.LocalDbOplogBacklog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a wire message and parses it back, so every round-trip below crosses a
|
||||||
|
/// real protobuf encode/decode rather than only exercising the mapper's object graph.
|
||||||
|
/// This is what proves the three collection wrapper messages keep their presence
|
||||||
|
/// when empty — an empty message encodes as a tag with zero-length payload, and a
|
||||||
|
/// mapper that used a bare <c>repeated</c>/<c>map</c> field instead would decode an
|
||||||
|
/// empty collection back as null with no test-visible difference at the object level.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Generated protobuf message type.</typeparam>
|
||||||
|
/// <param name="message">The message to send through an encode/decode cycle.</param>
|
||||||
|
/// <returns>An independent instance parsed from <paramref name="message"/>'s bytes.</returns>
|
||||||
|
private static T Wire<T>(T message) where T : IMessage<T>, new() =>
|
||||||
|
new MessageParser<T>(() => new T()).ParseFrom(message.ToByteArray());
|
||||||
|
|
||||||
|
private static ZB.MOM.WW.Audit.AuditEvent NewAuditEvent(string? sourceScript = null) =>
|
||||||
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
|
channel: AuditChannel.ApiOutbound,
|
||||||
|
kind: AuditKind.ApiCallCached,
|
||||||
|
status: AuditStatus.Forwarded,
|
||||||
|
eventId: Guid.NewGuid(),
|
||||||
|
occurredAtUtc: new DateTime(2026, 7, 22, 9, 0, 0, DateTimeKind.Utc),
|
||||||
|
target: "ERP.GetOrder",
|
||||||
|
sourceSiteId: "site-a",
|
||||||
|
sourceNode: "node-a",
|
||||||
|
sourceScript: sourceScript);
|
||||||
|
|
||||||
|
private static SiteCall NewSiteCall() => new()
|
||||||
|
{
|
||||||
|
TrackedOperationId = TrackedOperationId.New(),
|
||||||
|
Channel = "ApiOutbound",
|
||||||
|
Target = "ERP.GetOrder",
|
||||||
|
SourceSite = "site-a",
|
||||||
|
SourceNode = "node-a",
|
||||||
|
Status = "Delivered",
|
||||||
|
RetryCount = 2,
|
||||||
|
LastError = "transient 503",
|
||||||
|
HttpStatus = 200,
|
||||||
|
CreatedAtUtc = new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
UpdatedAtUtc = new DateTime(2026, 7, 22, 8, 5, 0, DateTimeKind.Utc),
|
||||||
|
TerminalAtUtc = new DateTime(2026, 7, 22, 8, 10, 0, DateTimeKind.Utc),
|
||||||
|
IngestedAtUtc = new DateTime(2026, 7, 22, 8, 10, 1, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user