using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Telemetry; /// /// Production seams for : the DB-backed node source and the /// gRPC dial loop. Kept out of the actor so the actor stays DB-free and gRPC-free (and therefore /// unit-testable with fakes). /// public static class TelemetryNodeSource { /// /// Builds the node source the supervisor polls: enabled, non-maintenance ClusterNode /// rows carrying a non-null GrpcPort, projected to http://{Host}:{GrpcPort} /// h2c endpoints. A row with a null GrpcPort exposes no telemetry surface yet and is /// skipped with a Warning — mirrors CentralCommunicationActor.LoadContactsFromDb's /// enabled + non-maintenance read. /// /// Factory for the config database holding ClusterNode rows. /// Logger for skipped-row diagnostics. /// An async delegate resolving the current dialable node set. public static Func>> Create( IDbContextFactory dbFactory, ILogger logger) { ArgumentNullException.ThrowIfNull(dbFactory); ArgumentNullException.ThrowIfNull(logger); // A persistently null-GrpcPort node is skipped on every refresh (every ContactRefreshSeconds); // warn once per node id so it does not spam the log forever. var warnedNullPort = new HashSet(StringComparer.Ordinal); return async () => { await using var db = await dbFactory.CreateDbContextAsync().ConfigureAwait(false); var rows = await db.ClusterNodes .AsNoTracking() .Where(n => n.Enabled && !n.MaintenanceMode) .Select(n => new { n.NodeId, n.Host, n.GrpcPort }) .ToListAsync() .ConfigureAwait(false); var targets = new List(rows.Count); foreach (var row in rows) { if (row.GrpcPort is null) { if (warnedNullPort.Add(row.NodeId)) { logger.LogWarning( "ClusterNode {NodeId} has no GrpcPort; it exposes no telemetry stream and is " + "skipped in this dial refresh (further skips of this node are silent)", row.NodeId); } continue; } targets.Add(new TelemetryDialTarget(row.NodeId, $"http://{row.Host}:{row.GrpcPort}")); } return (IReadOnlyList)targets; }; } /// /// Builds the production dial loop: one per stream, mapping /// every wire envelope via before handing it to /// the supervisor. The client isolates a mapper throw (logs + continues) and routes only /// transport failures to onError, so the supervisor sees a poison event as "dropped", /// never as "node down". /// /// Shared node bearer key sent on every dial. /// Logger passed to each per-stream client (dropped-event diagnostics). /// The dial-loop delegate the supervisor invokes per generation. public static TelemetryDialLoop CreateDialLoop( string apiKey, ILogger? clientLogger) { ArgumentNullException.ThrowIfNull(apiKey); return async (target, correlationId, onMapped, onError, ct) => { using var client = new TelemetryStreamClient(target.Endpoint, apiKey, clientLogger); await client.RunAsync( correlationId, onEvent: evt => onMapped(TelemetryProtoMapCentral.MapEvent(evt)), onError: onError, ct).ConfigureAwait(false); }; } }