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);
};
}
}