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
|
||||
#
|
||||
# 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
|
||||
# (Grpc.Tools 2.71.0). As a workaround the generated Sitestream.cs +
|
||||
# SitestreamGrpc.cs are checked into src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/
|
||||
# and the Protobuf ItemGroup in the .csproj is commented out — Docker just
|
||||
# compiles the checked-in C# files.
|
||||
# (Grpc.Tools 2.71.0). As a workaround the generated C# is checked into
|
||||
# src/ZB.MOM.WW.ScadaBridge.Communication/ — SiteStreamGrpc/ for sitestream.proto
|
||||
# and CentralControlGrpc/ for central_control.proto — and the Protobuf ItemGroup
|
||||
# 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.
|
||||
# 2. dotnet build (regen writes fresh files to obj/).
|
||||
# 3. Copies the regenerated files back into SiteStreamGrpc/.
|
||||
# 4. Re-comments the Protobuf ItemGroup so Docker builds stay safe.
|
||||
# docker/regen-proto.sh [sitestream|centralcontrol|all] (default: all)
|
||||
#
|
||||
# 1. Injects a Protobuf ItemGroup for the selected proto(s) so Grpc.Tools runs.
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
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)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMM_DIR="$REPO_ROOT/src/ZB.MOM.WW.ScadaBridge.Communication"
|
||||
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
|
||||
echo "ERROR: csproj not found at $CSPROJ" >&2
|
||||
exit 1
|
||||
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)"
|
||||
cp "$CSPROJ" "$BACKUP"
|
||||
trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR
|
||||
|
||||
# 1. Uncomment the Protobuf ItemGroup (strip the surrounding <!-- ... --> wrapper).
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
# Find the commented Protobuf block and unwrap it.
|
||||
new = re.sub(
|
||||
r"<!--\s*\n(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)\s*\n\s*-->",
|
||||
r"\1",
|
||||
src,
|
||||
count=1,
|
||||
)
|
||||
if new == src:
|
||||
raise SystemExit("Couldn't find commented Protobuf ItemGroup to enable.")
|
||||
p.write_text(new)
|
||||
# 1. Inject an ItemGroup holding just the selected protos, immediately before
|
||||
# the closing </Project>. The documented commented-out block is left alone.
|
||||
python3 - "$CSPROJ" "$TARGET" <<'PY'
|
||||
import pathlib, sys
|
||||
|
||||
csproj, target = pathlib.Path(sys.argv[1]), sys.argv[2]
|
||||
|
||||
protos = []
|
||||
if target in ("sitestream", "all"):
|
||||
protos.append("sitestream.proto")
|
||||
if target in ("centralcontrol", "all"):
|
||||
protos.append("central_control.proto")
|
||||
|
||||
items = "\n".join(
|
||||
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
|
||||
|
||||
# 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.
|
||||
echo "Building Communication project (regen)..."
|
||||
dotnet build "$CSPROJ" --nologo -v minimal | tail -5
|
||||
|
||||
# 4. Copy generated files back into the source tree.
|
||||
mkdir -p "$GEN_DIR"
|
||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/Sitestream.cs" "$GEN_DIR/Sitestream.cs"
|
||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/SitestreamGrpc.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
||||
echo "Copied regenerated files to $GEN_DIR/"
|
||||
|
||||
# 5. Re-comment the Protobuf ItemGroup so Docker builds keep working.
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
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
|
||||
if [[ "$TARGET" == "sitestream" || "$TARGET" == "all" ]]; then
|
||||
mkdir -p "$COMM_DIR/SiteStreamGrpc"
|
||||
cp "$GEN/Sitestream.cs" "$GEN/SitestreamGrpc.cs" "$COMM_DIR/SiteStreamGrpc/"
|
||||
echo "Copied regenerated files to SiteStreamGrpc/"
|
||||
fi
|
||||
if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then
|
||||
mkdir -p "$COMM_DIR/CentralControlGrpc"
|
||||
cp "$GEN/CentralControl.cs" "$GEN/CentralControlGrpc.cs" "$COMM_DIR/CentralControlGrpc/"
|
||||
echo "Copied regenerated files to CentralControlGrpc/"
|
||||
fi
|
||||
|
||||
# 5. Restore the backed-up csproj — i.e. drop the injected ItemGroup — so Docker
|
||||
# builds keep working.
|
||||
cp "$BACKUP" "$CSPROJ"
|
||||
rm -f "$BACKUP"
|
||||
trap - ERR
|
||||
|
||||
echo ""
|
||||
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/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"/>.
|
||||
/// </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="ToDto"/> projects a site-local <see cref="SiteCallOperational"/>
|
||||
/// onto the wire — used by the Site Call Audit <c>PullSiteCalls</c>
|
||||
/// reconciliation handler (the central→site self-heal pull). The
|
||||
/// <see cref="SiteCall"/> entity itself is never mapped back onto the wire:
|
||||
/// sites emit operational state from <see cref="SiteCallOperational"/>, never
|
||||
/// from the central <see cref="SiteCall"/>, so a <c>SiteCall</c>→DTO method
|
||||
/// would be dead code.
|
||||
/// <see cref="ToDto(SiteCallOperational)"/> projects a site-local
|
||||
/// <see cref="SiteCallOperational"/> onto the wire — used by the Site Call Audit
|
||||
/// <c>PullSiteCalls</c> reconciliation handler (the central→site self-heal pull).
|
||||
/// <see cref="ToDto(SiteCall)"/> projects the entity form back onto the wire; it
|
||||
/// exists for the gRPC central control plane, whose site-side transport receives
|
||||
/// an already-decoded <c>IngestCachedTelemetryCommand</c> (which carries
|
||||
/// <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>
|
||||
/// String nullability convention: proto3 scalar strings cannot be absent, so the
|
||||
@@ -120,6 +122,51 @@ public static class SiteCallDtoMapper
|
||||
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
|
||||
// requires UTC kind. Specify (never convert) so a row read back from SQLite
|
||||
// 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" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- gRPC proto generation. The compiled C# is checked in under
|
||||
SiteStreamGrpc/ (Sitestream.cs + SitestreamGrpc.cs) because protoc
|
||||
segfaults inside our linux_arm64 Docker build image. To regenerate
|
||||
after schema changes:
|
||||
1. Temporarily uncomment the Protobuf ItemGroup below.
|
||||
2. Delete SiteStreamGrpc/*.cs.
|
||||
<!-- gRPC proto generation. The compiled C# is checked in — SiteStreamGrpc/
|
||||
(Sitestream.cs + SitestreamGrpc.cs) for Protos/sitestream.proto, and
|
||||
CentralControlGrpc/ (CentralControl.cs + CentralControlGrpc.cs) for
|
||||
Protos/central_control.proto — because protoc segfaults inside our
|
||||
linux_arm64 Docker build image. To regenerate after schema changes run
|
||||
`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/.
|
||||
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.
|
||||
Eventually we should switch the Docker build image to one with a
|
||||
working protoc on arm64. -->
|
||||
central_control.proto imports sitestream.proto, so protoc resolves it
|
||||
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>
|
||||
<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />
|
||||
<Protobuf Include="Protos\central_control.proto" GrpcServices="Both" />
|
||||
</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