d7455577a8
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.
765 lines
32 KiB
C#
765 lines
32 KiB
C#
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);
|
|
}
|