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