Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ChildHealthLedger.cs
T
Joseph Doherty d1cc9e2cfc feat(runtime,adminui): real GetDiagnostics from child health + /fleet/diagnostics consumer (A6)
DriverHostActor wraps the health publisher it hands its children in a
ChildHealthLedger (last snapshot per driver id, forwarded to the real
publisher), so GetDiagnostics now answers with each child's row id,
authored name, last-published state, device counts (host statuses when
the driver probes hosts, else one device) and last-change time — instead
of Guid.Empty / "Spawned" / 0 / now. Entries are forgotten on StopChild
and operator Restart.

IFleetDiagnosticsClient gains its first consumer: the new
/fleet/diagnostics page (node selector + ?node= query, revision,
running-from-cache notice, per-driver table). FleetDiagnosticsClient
logs the degrade-to-empty path at Warning.

Review ref: 02/U1.

Refs #731

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 06:44:38 -04:00

74 lines
3.7 KiB
C#

using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>The last health snapshot a driver child published, as kept by <see cref="ChildHealthLedger"/>.</summary>
/// <param name="State">The driver's reported state.</param>
/// <param name="LastSuccessfulReadUtc">When the driver last read successfully, if ever.</param>
/// <param name="LastError">The driver's last error, if any.</param>
/// <param name="HostStatuses">Per-host connectivity for a multi-host driver, or null when the driver has none.</param>
/// <param name="PublishedUtc">When the snapshot was published — the child's dedup means this is the last CHANGE.</param>
public sealed record ChildHealthRecord(
DriverState State,
DateTime? LastSuccessfulReadUtc,
string? LastError,
IReadOnlyList<HostConnectivityStatus>? HostStatuses,
DateTime PublishedUtc);
/// <summary>
/// An <see cref="IDriverHealthPublisher"/> decorator that remembers the last snapshot per driver instance
/// and forwards every publish to the real publisher. <see cref="DriverHostActor"/> wraps the publisher it
/// hands its children in one of these so <c>GetDiagnostics</c> can answer from the children's own health
/// (state, host counts, last change) instead of a placeholder — the children publish on their own actor
/// threads, hence the concurrent map.
/// </summary>
/// <remarks>
/// Deliberately NOT a message to the host: the children already publish through this seam on every
/// change, so a parallel <c>Tell</c> would double every health transition's traffic for one consumer.
/// Entries are removed by the host when it stops a child; a late publish from a stopping child may re-add
/// one, which the host filters against its live child set when it answers.
/// </remarks>
public sealed class ChildHealthLedger : IDriverHealthPublisher
{
private readonly IDriverHealthPublisher _inner;
private readonly ConcurrentDictionary<string, ChildHealthRecord> _last = new(StringComparer.Ordinal);
/// <summary>Wraps <paramref name="inner"/>.</summary>
/// <param name="inner">The publisher every snapshot is forwarded to.</param>
public ChildHealthLedger(IDriverHealthPublisher inner)
{
ArgumentNullException.ThrowIfNull(inner);
_inner = inner;
}
/// <inheritdoc />
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{
_last[driverInstanceId] = new ChildHealthRecord(
health.State, health.LastSuccessfulRead, health.LastError, hostStatuses, DateTime.UtcNow);
_inner.Publish(
clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason,
hostStatuses, skippedTags, diagnostics);
}
/// <summary>The last snapshot published for <paramref name="driverInstanceId"/>, or null if none yet.</summary>
/// <param name="driverInstanceId">The driver instance.</param>
/// <returns>The record, or null.</returns>
public ChildHealthRecord? TryGet(string driverInstanceId) =>
_last.TryGetValue(driverInstanceId, out var record) ? record : null;
/// <summary>Forgets <paramref name="driverInstanceId"/>'s snapshot (the child was stopped).</summary>
/// <param name="driverInstanceId">The driver instance.</param>
public void Forget(string driverInstanceId) => _last.TryRemove(driverInstanceId, out _);
}