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
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
@@ -13,7 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
/// Sends <see cref="GetDiagnostics"/>; expects a <see cref="NodeDiagnosticsSnapshot"/> reply.
|
||||
///
|
||||
/// On timeout or any other failure (peer down, GetDiagnostics handler missing) returns an
|
||||
/// empty snapshot so the UI degrades to "no data" instead of throwing.
|
||||
/// empty snapshot so the UI degrades to "no data" instead of throwing. The failure is logged at
|
||||
/// Warning, since the snapshot shape cannot carry it: an empty snapshot reads the same as a node with
|
||||
/// no drivers. Consumed by the <c>/fleet/diagnostics</c> page.
|
||||
/// </summary>
|
||||
public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
|
||||
{
|
||||
@@ -21,14 +25,18 @@ public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
|
||||
|
||||
private readonly ActorSystem _system;
|
||||
private readonly string _systemName;
|
||||
private readonly ILogger<FleetDiagnosticsClient> _log;
|
||||
|
||||
/// <summary>Initializes a new FleetDiagnosticsClient with the given actor system and cluster options.</summary>
|
||||
/// <param name="system">The Akka actor system.</param>
|
||||
/// <param name="options">Cluster configuration options.</param>
|
||||
public FleetDiagnosticsClient(ActorSystem system, IOptions<AkkaClusterOptions> options)
|
||||
/// <param name="log">Logger for the degraded-to-empty path; defaults to a no-op logger.</param>
|
||||
public FleetDiagnosticsClient(
|
||||
ActorSystem system, IOptions<AkkaClusterOptions> options, ILogger<FleetDiagnosticsClient>? log = null)
|
||||
{
|
||||
_system = system;
|
||||
_systemName = options.Value.SystemName;
|
||||
_log = log ?? NullLogger<FleetDiagnosticsClient>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -42,8 +50,11 @@ public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
|
||||
return await selection.Ask<NodeDiagnosticsSnapshot>(
|
||||
new GetDiagnostics(CorrelationId.NewId()), AskTimeout, linked.Token);
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_log.LogWarning(ex,
|
||||
"Fleet diagnostics for node {NodeId} unavailable (no answer within {Timeout}s); returning an empty snapshot",
|
||||
nodeId.Value, AskTimeout.TotalSeconds);
|
||||
return new NodeDiagnosticsSnapshot(
|
||||
nodeId,
|
||||
CurrentRevision: null,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
@page "/fleet/diagnostics"
|
||||
@* Per-node driver diagnostics, fetched on demand from the selected driver node's DriverHostActor over
|
||||
Akka (IFleetDiagnosticsClient → GetDiagnostics → NodeDiagnosticsSnapshot). The node answers from
|
||||
its live child set + each child's last-published health; the "running from cache" flag is the one
|
||||
fact /hosts cannot show (a node booted from its artifact cache looks healthy but is frozen).
|
||||
Reach it as /fleet/diagnostics?node=<host:port> (the NodeId shown on /fleet and /hosts), or pick
|
||||
a driver-role member from the selector. *@
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.OtOpcUa.Cluster
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@inject IClusterRoleInfo Cluster
|
||||
@inject IFleetDiagnosticsClient Diagnostics
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageErrorBoundary>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Node diagnostics</h4>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/fleet">Back to fleet status</a>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
||||
<label class="small text-muted mb-0" for="diag-node">Node</label>
|
||||
<select id="diag-node" class="form-select form-select-sm" style="width:auto"
|
||||
value="@_selectedNode" @onchange="OnNodeSelected">
|
||||
<option value="">— select a driver node —</option>
|
||||
@foreach (var member in _members)
|
||||
{
|
||||
<option value="@member">@member</option>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_selectedNode) && !_members.Contains(_selectedNode))
|
||||
{
|
||||
<option value="@_selectedNode">@_selectedNode (not an Up member)</option>
|
||||
}
|
||||
</select>
|
||||
<input type="text" class="form-control form-control-sm" style="width:220px"
|
||||
placeholder="or type host:port…" @bind="_typedNode" @bind:event="oninput"
|
||||
@onkeydown="OnTypedKeyDown" />
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="UseTypedNodeAsync"
|
||||
disabled="@(string.IsNullOrWhiteSpace(_typedNode))">Go</button>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync"
|
||||
disabled="@(_loading || string.IsNullOrEmpty(_selectedNode))">
|
||||
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Last fetched: @(_fetchedUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (string.IsNullOrEmpty(_selectedNode))
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Pick a driver-role node to fetch its driver diagnostics. The node answers from its own
|
||||
DriverHostActor, so this works for any node reachable over Akka remoting from this admin node.
|
||||
</section>
|
||||
}
|
||||
else if (_snapshot is null)
|
||||
{
|
||||
<p>@(_loading ? "Fetching…" : "No snapshot yet.")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Node</div>
|
||||
<div class="agg-value mono">@_snapshot.NodeId.Value</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Revision</div>
|
||||
<div class="agg-value mono" title="@(_snapshot.CurrentRevision?.Value ?? "")">
|
||||
@(_snapshot.CurrentRevision is { } rev ? DisplayText.Abbreviate(rev.Value, 12) : "—")
|
||||
</div>
|
||||
</div>
|
||||
<div class="agg-card @(_snapshot.RunningFromCache ? "caution" : "")">
|
||||
<div class="agg-label">Config source</div>
|
||||
<div class="agg-value">@(_snapshot.RunningFromCache ? "cache (frozen)" : "live")</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Drivers</div>
|
||||
<div class="agg-value numeric">@_snapshot.Drivers.Count</div>
|
||||
</div>
|
||||
<div class="agg-card alert">
|
||||
<div class="agg-label">Faulted devices</div>
|
||||
<div class="agg-value numeric">@_snapshot.Drivers.Sum(d => d.FaultedDevices)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_snapshot.RunningFromCache)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.05s">
|
||||
This node booted its configuration from its local artifact cache because central was unreachable.
|
||||
It serves its last-known-good address space, but no deployment can reach it until central is back.
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">
|
||||
Drivers
|
||||
<span class="text-muted small ms-2">as of @_snapshot.AsOfUtc.ToString("u")</span>
|
||||
</div>
|
||||
@if (_snapshot.Drivers.Count == 0)
|
||||
{
|
||||
<div class="p-3 text-muted">
|
||||
Empty snapshot: the node has no driver children — or it did not answer within the client's
|
||||
3 s budget (an unreachable node degrades to an empty snapshot; see the AdminUI log for the
|
||||
FleetDiagnosticsClient warning).
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Driver</th>
|
||||
<th>Row id</th>
|
||||
<th>State</th>
|
||||
<th class="numeric">Connected devices</th>
|
||||
<th class="numeric">Faulted devices</th>
|
||||
<th>Last change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<tr>
|
||||
<td>@d.Name</td>
|
||||
<td><span class="mono" title="@d.DriverInstanceId">@(d.DriverInstanceId == Guid.Empty ? "—" : DisplayText.Abbreviate(d.DriverInstanceId.ToString(), 13))</span></td>
|
||||
<td><span class="chip @StateChipClass(d.State)">@d.State</span></td>
|
||||
<td class="numeric">@d.ConnectedDevices</td>
|
||||
<td class="numeric">@d.FaultedDevices</td>
|
||||
<td>@d.LastChangeUtc.ToString("u")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</PageErrorBoundary>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery(Name = "node")]
|
||||
public string? Node { get; set; }
|
||||
|
||||
private IReadOnlyList<string> _members = Array.Empty<string>();
|
||||
private string _selectedNode = "";
|
||||
private string _typedNode = "";
|
||||
private NodeDiagnosticsSnapshot? _snapshot;
|
||||
private bool _loading;
|
||||
private DateTime? _fetchedUtc;
|
||||
|
||||
// Page-lifetime cancellation: a fetch in flight when the circuit goes away is abandoned.
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private volatile bool _disposed;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_members = Cluster.MembersWithRole(RoleParser.Driver)
|
||||
.Select(n => n.Value)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var requested = Node?.Trim() ?? "";
|
||||
if (!string.Equals(requested, _selectedNode, StringComparison.Ordinal))
|
||||
{
|
||||
_selectedNode = requested;
|
||||
_snapshot = null;
|
||||
if (!string.IsNullOrEmpty(_selectedNode)) await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnNodeSelected(ChangeEventArgs e)
|
||||
{
|
||||
var value = e.Value?.ToString() ?? "";
|
||||
Navigate(value);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task UseTypedNodeAsync()
|
||||
{
|
||||
Navigate(_typedNode.Trim());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnTypedKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter") Navigate(_typedNode.Trim());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// The node id lives in the URL so a diagnostics view is linkable from /fleet and survives a reload.
|
||||
private void Navigate(string node) =>
|
||||
Nav.NavigateTo(string.IsNullOrEmpty(node)
|
||||
? "/fleet/diagnostics"
|
||||
: "/fleet/diagnostics?node=" + Uri.EscapeDataString(node));
|
||||
|
||||
private async Task RefreshAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading || string.IsNullOrEmpty(_selectedNode)) return;
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
// The client is fail-safe by contract: a node that does not answer yields an empty snapshot.
|
||||
_snapshot = await Diagnostics.GetDiagnosticsAsync(NodeId.Parse(_selectedNode), _cts.Token);
|
||||
_fetchedUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
// Circuit gone mid-fetch — a clean exit, not a fault.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
if (!_disposed) StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string StateChipClass(string state) => state switch
|
||||
{
|
||||
"Healthy" => "chip-ok",
|
||||
"Degraded" or "Connecting" or "Reconnecting" => "chip-caution",
|
||||
"Faulted" => "chip-alert",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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 _);
|
||||
}
|
||||
@@ -195,7 +195,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
private readonly IReadOnlySet<string> _localRoles;
|
||||
private readonly IActorRef? _dependencyMux;
|
||||
private readonly IActorRef? _opcUaPublishActor;
|
||||
private readonly IDriverHealthPublisher _healthPublisher;
|
||||
/// <summary>The publisher handed to every child — a <see cref="ChildHealthLedger"/> over the configured one, so
|
||||
/// <see cref="HandleGetDiagnostics"/> answers from the children's own last-published health.</summary>
|
||||
private readonly ChildHealthLedger _healthPublisher;
|
||||
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
|
||||
private readonly IHistoryWriter _historyWriter;
|
||||
private readonly IActorRef? _virtualTagHostOverride;
|
||||
@@ -594,6 +596,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
|
||||
private sealed record ChildEntry(IActorRef Actor, DriverInstanceSpec Spec, bool Stubbed)
|
||||
{
|
||||
/// <summary>When this child was spawned — the diagnostics' last-change time until it publishes health.</summary>
|
||||
public DateTime SpawnedUtc { get; } = DateTime.UtcNow;
|
||||
|
||||
// Convenience accessors for sites that don't need the full spec.
|
||||
/// <summary>Gets the driver type name from the child's spec.</summary>
|
||||
public string DriverType => Spec.DriverType;
|
||||
@@ -886,7 +891,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
_dependencyMux = dependencyMux;
|
||||
_opcUaPublishActor = opcUaPublishActor;
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
_healthPublisher = new ChildHealthLedger(healthPublisher ?? NullDriverHealthPublisher.Instance);
|
||||
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
|
||||
_historyWriter = historyWriter ?? NullHistoryWriter.Instance;
|
||||
_virtualTagHostOverride = virtualTagHostOverride;
|
||||
@@ -2514,16 +2519,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers <see cref="GetDiagnostics"/> from the live child set and each child's last-published health
|
||||
/// (<see cref="ChildHealthLedger"/>). Used to be a placeholder — <c>Guid.Empty</c>, "Spawned", zero
|
||||
/// devices, "now" — that looked live in every behaviour and was read by nobody (02/U1); the AdminUI
|
||||
/// fleet-diagnostics page is its consumer.
|
||||
/// </summary>
|
||||
private void HandleGetDiagnostics(GetDiagnostics msg)
|
||||
{
|
||||
var drivers = _children
|
||||
.Select(kv => new DriverInstanceDiagnostics(
|
||||
DriverInstanceId: Guid.Empty,
|
||||
Name: kv.Key,
|
||||
State: kv.Value.Stubbed ? "Stubbed" : "Spawned",
|
||||
ConnectedDevices: 0,
|
||||
FaultedDevices: 0,
|
||||
LastChangeUtc: DateTime.UtcNow))
|
||||
.Select(kv => ToDiagnostics(kv.Key, kv.Value, _healthPublisher.TryGet(kv.Key)))
|
||||
.ToArray();
|
||||
var snapshot = new NodeDiagnosticsSnapshot(
|
||||
NodeId: _localNode,
|
||||
@@ -2534,6 +2539,37 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Sender.Tell(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One child's diagnostics row. Device counts come from the driver's host statuses when it is an
|
||||
/// <c>IHostConnectivityProbe</c> (Running = connected; Stopped/Faulted = faulted; Unknown = neither);
|
||||
/// a single-host driver counts as one device, connected while it reports Healthy or Degraded and
|
||||
/// faulted while it reports Faulted. A child that has not published yet reports "Connecting" (or
|
||||
/// "Stubbed") with its spawn time as the last change.
|
||||
/// </summary>
|
||||
private static DriverInstanceDiagnostics ToDiagnostics(string driverInstanceId, ChildEntry entry, ChildHealthRecord? health)
|
||||
{
|
||||
var name = string.IsNullOrEmpty(entry.Spec.Name) ? driverInstanceId : entry.Spec.Name;
|
||||
if (entry.Stubbed)
|
||||
return new DriverInstanceDiagnostics(entry.Spec.DriverInstanceRowId, name, "Stubbed", 0, 0, entry.SpawnedUtc);
|
||||
if (health is null)
|
||||
return new DriverInstanceDiagnostics(entry.Spec.DriverInstanceRowId, name, "Connecting", 0, 0, entry.SpawnedUtc);
|
||||
|
||||
int connected, faulted;
|
||||
if (health.HostStatuses is { Count: > 0 } hosts)
|
||||
{
|
||||
connected = hosts.Count(h => h.State == HostState.Running);
|
||||
faulted = hosts.Count(h => h.State is HostState.Stopped or HostState.Faulted);
|
||||
}
|
||||
else
|
||||
{
|
||||
connected = health.State is DriverState.Healthy or DriverState.Degraded ? 1 : 0;
|
||||
faulted = health.State == DriverState.Faulted ? 1 : 0;
|
||||
}
|
||||
|
||||
return new DriverInstanceDiagnostics(
|
||||
entry.Spec.DriverInstanceRowId, name, health.State.ToString(), connected, faulted, health.PublishedUtc);
|
||||
}
|
||||
|
||||
private void HandleDispatchFromSteady(DispatchDeployment msg)
|
||||
{
|
||||
// The short-circuit claims a CLEAN Applied, so it is only honest while the current revision's
|
||||
@@ -3794,6 +3830,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// backoff it never earned (its first crash was deferred as a "crash loop").
|
||||
_lastReprovisionUtc.Remove(driverInstanceId);
|
||||
Timers.Cancel(ReprovisionTimerKey(driverInstanceId));
|
||||
_healthPublisher.Forget(driverInstanceId);
|
||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||
}
|
||||
|
||||
@@ -3844,6 +3881,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
StopDependencyAdapter(msg.DriverInstanceId);
|
||||
Context.Stop(entry.Actor);
|
||||
_children.Remove(msg.DriverInstanceId);
|
||||
// The old incarnation's health must not read as the new one's until it publishes its own.
|
||||
_healthPublisher.Forget(msg.DriverInstanceId);
|
||||
|
||||
// Respawn from the same spec the last reconcile used — preserves RowId, Name, ClusterId.
|
||||
SpawnChild(entry.Spec);
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 U1: <c>GetDiagnostics</c> answered a placeholder — <c>Guid.Empty</c>, "Spawned", zero devices and
|
||||
/// "now" for every child, in every behaviour — so the snapshot looked live and said nothing. It now carries
|
||||
/// each child's row id, authored name, last-published health state, device counts and last change time.
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorDiagnosticsTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
|
||||
private const string DriverId = "DI-1";
|
||||
|
||||
[Fact]
|
||||
public async Task A_connected_child_reports_its_row_id_name_health_state_and_device_count()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var rowId = Guid.NewGuid();
|
||||
var before = DateTime.UtcNow.AddSeconds(-1);
|
||||
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||
db, RevA, new[] { (DriverId, "Line1-Modbus", "dev1", "speed", "Float", false) },
|
||||
Array.Empty<(string, string, string, string, string?)>(),
|
||||
enabled: true, driverRowIds: new Dictionary<string, Guid> { [DriverId] = rowId });
|
||||
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
||||
|
||||
var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Any(d => d.State == "Healthy"));
|
||||
|
||||
snapshot.NodeId.ShouldBe(TestNode);
|
||||
snapshot.CurrentRevision.ShouldBe(RevA);
|
||||
snapshot.RunningFromCache.ShouldBeFalse();
|
||||
var driver = snapshot.Drivers.ShouldHaveSingleItem();
|
||||
driver.DriverInstanceId.ShouldBe(rowId, "the real row id, not Guid.Empty");
|
||||
driver.Name.ShouldBe("Line1-Modbus", "the authored name, not the instance id");
|
||||
driver.State.ShouldBe("Healthy");
|
||||
driver.ConnectedDevices.ShouldBe(1);
|
||||
driver.FaultedDevices.ShouldBe(0);
|
||||
driver.LastChangeUtc.ShouldBeGreaterThan(before);
|
||||
driver.LastChangeUtc.ShouldBeLessThanOrEqualTo(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stubbed_child_reports_Stubbed_with_no_devices()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// The factory answers for a type the artifact does not use, so the host stubs the child.
|
||||
var factory = new RecordingSubscribableDriverFactory("NotModbus");
|
||||
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||
db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
||||
Array.Empty<(string, string, string, string, string?)>(), enabled: true);
|
||||
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
||||
|
||||
var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Count == 1);
|
||||
|
||||
var driver = snapshot.Drivers.Single();
|
||||
driver.State.ShouldBe("Stubbed");
|
||||
driver.ConnectedDevices.ShouldBe(0);
|
||||
driver.FaultedDevices.ShouldBe(0);
|
||||
}
|
||||
|
||||
private async Task<NodeDiagnosticsSnapshot> AwaitDiagnosticsAsync(IActorRef host, Func<NodeDiagnosticsSnapshot, bool> ready)
|
||||
{
|
||||
NodeDiagnosticsSnapshot? snapshot = null;
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
snapshot = await host.Ask<NodeDiagnosticsSnapshot>(new GetDiagnostics(CorrelationId.NewId()), PresenceBudget);
|
||||
ready(snapshot).ShouldBeTrue();
|
||||
}, PresenceBudget);
|
||||
return snapshot!;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public static class V3ArtifactSeeder
|
||||
/// is parsed out of <c>TagConfig.alarm</c> by <c>TagConfigIntent</c>), which the alarm-ref half of the
|
||||
/// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried
|
||||
/// in the alarm one instead, so the two travel through different child code paths.</param>
|
||||
/// <param name="driverRowIds">Optional <c>DriverInstanceRowId</c> (the DB row Guid) per driver id; absent
|
||||
/// ids get <see cref="Guid.Empty"/>, which is what an artifact without the property parses to.</param>
|
||||
/// <param name="taglessDrivers">Extra <c>DriverInstance</c> ids carrying NO devices and NO tags. The
|
||||
/// only way to express a driver that is a running child yet absent from both the value- and alarm-ref
|
||||
/// maps — the <c>_children.Keys</c> arm of <c>ApplyDesiredSubscriptions</c>'s union, which must still
|
||||
@@ -58,14 +60,16 @@ public static class V3ArtifactSeeder
|
||||
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
|
||||
bool enabled = false,
|
||||
IReadOnlyDictionary<string, string>? tagConfigByTag = null,
|
||||
string[]? taglessDrivers = null)
|
||||
string[]? taglessDrivers = null,
|
||||
IReadOnlyDictionary<string, Guid>? driverRowIds = null)
|
||||
{
|
||||
Guid RowId(string id) => driverRowIds is not null && driverRowIds.TryGetValue(id, out var g) ? g : Guid.Empty;
|
||||
var drivers = rawTags
|
||||
.Select(t => (t.Driver, t.DriverName))
|
||||
.Distinct()
|
||||
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
|
||||
.Select(d => new { DriverInstanceRowId = RowId(d.Driver), DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
|
||||
.Concat((taglessDrivers ?? Array.Empty<string>())
|
||||
.Select(id => new { DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
|
||||
.Select(id => new { DriverInstanceRowId = RowId(id), DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
|
||||
.ToArray();
|
||||
var devices = rawTags
|
||||
.Select(t => (t.Driver, t.Device))
|
||||
|
||||
Reference in New Issue
Block a user