Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs
T
Joseph Doherty 63c16d6912 refactor(comm): retire ClusterClient naming after the gRPC cutover
Phase 4 of the ClusterClient -> gRPC migration deleted the Akka transports
but left the naming behind: `ClusterClientSiteAuditClient` was transport-
agnostic and worked unchanged, so it survived the deletion under a name
that now describes a transport the repo no longer has. Same for a scatter
of doc-comments still framing gRPC as "the new transport" beside an Akka
one that is gone.

Renames it to `SiteCommunicationAuditClient` (and its test file) and
rewrites the stale comments to describe the single transport that exists.
Also tightens CLAUDE.md: drops the self-describing directory listing and
the 27-component enumeration in favour of the non-obvious parts only.

Behaviour-neutral: names and prose only. Recorded as the Phase 4
follow-up in docs/plans/2026-07-22-clusterclient-to-grpc-plan.md.
2026-07-27 15:40:01 -04:00

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
/// the control plane. The in-process and wire representations carry the SAME message types
/// end-to-end — central's handlers were 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);
}