Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs
T
Joseph Doherty d33e4e20b5 feat(adminui): #546 wire DriverStatusPanel into /hosts driver rows (collapsed expander)
DriverStatusPanel was orphaned — zero references repo-wide — so the Restart and
Reconnect driver commands had NO reachable UI at all, despite working end to end
over the new gRPC mesh transport. Nothing failed to build, test or deploy while
that was true, which is why it went unnoticed.

The panel is now reachable per driver row on /hosts, behind a per-row "Controls"
expander that is collapsed by default: the table stays scannable, and only the
opened panels hold snapshot-store subscriptions. Expansion state is a HashSet
keyed by DriverInstanceId held on the page, so it survives the 5 s refresh that
replaces every row record. Both rows are @key'd by instance id so the panel —
which binds its DriverInstanceId once in OnInitializedAsync — can never be diffed
onto a different driver.

The panel's Enabled parameter is fed from real config rather than defaulted:
HostsDriverInstanceInfo/HostsDriverRow now carry DriverInstance.Enabled, which
LoadConfigAsync already had in hand. A snapshot with no matching configured
instance is treated as enabled — the row exists only because live health arrived,
so "Disabled — not deployed" would contradict the same row.

The expander is visible to every AuthenticatedRead viewer; the panel self-gates
only its Reconnect/Restart buttons on DriverOperator.

A source-scan pin (HostsPanelWiringTests) asserts /hosts still renders the panel
and still binds it to the row's own driver, cluster and enabled state — removing
the tag would silently restore the orphaned state.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:18:37 -04:00

192 lines
11 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// One configured host node within a cluster, as the <c>/hosts</c> page needs it: the cluster
/// it belongs to, its node id, and the host/port its OPC UA endpoint binds.
/// </summary>
/// <param name="ClusterId">Cluster this node belongs to.</param>
/// <param name="NodeId">Stable node identifier within the cluster.</param>
/// <param name="Host">Host name or address the node's OPC UA server binds.</param>
/// <param name="OpcUaPort">OPC UA TCP port the node listens on.</param>
public sealed record HostsNodeInfo(string ClusterId, string NodeId, string Host, int OpcUaPort);
/// <summary>
/// A configured driver instance's identity, used to enrich a live health snapshot with the
/// authored display name and driver type. Sourced from the config DB by the caller; this type
/// is deliberately DB-agnostic so the builder stays pure and unit-testable.
/// </summary>
/// <param name="DriverInstanceId">Globally-unique driver instance id (joins to a snapshot).</param>
/// <param name="ClusterId">Cluster the instance is configured under.</param>
/// <param name="Name">Authored display name.</param>
/// <param name="DriverType">Driver type string (Modbus, S7, Galaxy, ...).</param>
/// <param name="Enabled">Whether the instance is authored enabled — forwarded to the row's
/// <c>DriverStatusPanel</c>, which renders a "Disabled — not deployed" notice and opens no
/// subscription when false.</param>
public sealed record HostsDriverInstanceInfo(
string DriverInstanceId, string ClusterId, string Name, string DriverType, bool Enabled = true);
/// <summary>
/// One row in the <c>/hosts</c> "Driver Instances" table: a live health snapshot enriched with
/// the configured name + type (both null when no matching instance is configured).
/// </summary>
/// <param name="DriverInstanceId">Globally-unique driver instance id.</param>
/// <param name="Name">Configured display name; null when unknown.</param>
/// <param name="DriverType">Configured driver type; null when unknown.</param>
/// <param name="State">Last-known driver state string (Healthy, Faulted, Reconnecting, ...).</param>
/// <param name="LastSuccessfulReadUtc">Most recent successful equipment read; null if never.</param>
/// <param name="LastError">Latest error message; null when none.</param>
/// <param name="ErrorCount5Min">Faulted-transition count in the last 5 minutes.</param>
/// <param name="PublishedUtc">Timestamp the snapshot was published.</param>
/// <param name="RediscoveryNeededUtc">When the driver last reported that the remote's tag set may have
/// changed; null when never (or when the driver cannot report it). Advisory — the served address space
/// is unchanged and an operator must re-browse the device via <c>/raw</c> to pick anything up.</param>
/// <param name="RediscoveryReason">The driver-supplied reason for that report; null when
/// <paramref name="RediscoveryNeededUtc"/> is null.</param>
/// <param name="HostStatuses">Per-host connectivity for a multi-device driver, ordered by host name; null
/// when the driver reports no per-host detail. Lets one unreachable device show even while the driver
/// row itself is Healthy.</param>
/// <param name="SkippedTags">Authored tags the driver could not build into its poll table; null when there
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
/// count, last error — reads perfectly healthy (Gitea #541).</param>
/// <param name="Enabled">Whether the matching configured instance is authored enabled; <see langword="true"/>
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c> (Gitea #546). An
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
/// so telling the operator it is "not deployed" would contradict the evidence in front of them.</param>
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
SkippedTagReport? SkippedTags = null,
bool Enabled = true)
{
/// <summary>Whether this driver is ignoring at least one authored tag — the condition the whole
/// column exists to make visible.</summary>
public bool HasSkippedTags => SkippedTags is { SkippedCount: > 0 };
/// <summary>
/// Operator-facing detail for the skip chip: the sample of skipped RawPaths with reasons, plus an
/// explicit note when the sample is shorter than the count. The truncation note is not optional —
/// without it a capped list reads as the complete one, and an operator who fixes those twenty
/// believes they are done.
/// </summary>
/// <returns>The tooltip text; empty when nothing was skipped.</returns>
public string SkippedTagsTitle()
{
if (SkippedTags is not { SkippedCount: > 0 } r) return "";
var lead = r.SkippedEverything
? $"This driver is polling NONE of its {r.AuthoredCount} authored tag(s)."
: $"{r.SkippedCount} of {r.AuthoredCount} authored tag(s) are not being polled.";
var detail = string.Join(" · ", r.Sample.Select(s => $"{s.RawPath}: {s.Reason}"));
var more = r.SkippedCount > r.Sample.Count
? $" (+{r.SkippedCount - r.Sample.Count} more not shown)"
: "";
return $"{lead} They report BadNodeIdUnknown and the driver's state does not reflect them. {detail}{more}";
}
/// <summary>Hosts that are not <see cref="HostState.Running"/>, ordered by name — the ones worth an
/// operator's attention. Empty when every host is fine or none are reported.
/// <para><see cref="HostState.Unknown"/> counts as degraded on purpose: a probe that has not yet
/// completed its first tick, or one a driver failed to start (AbCip logs exactly this case), reports
/// Unknown — and silently rendering that as healthy is how the gap got missed the first time.</para></summary>
public IReadOnlyList<HostConnectivityStatus> DegradedHosts { get; } =
(HostStatuses ?? [])
.Where(h => h.State != HostState.Running)
.OrderBy(h => h.HostName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// One cluster's section on the <c>/hosts</c> page: its configured nodes plus its enriched
/// driver-instance rows.
/// </summary>
/// <param name="ClusterId">Cluster identifier.</param>
/// <param name="Nodes">Configured nodes in this cluster, ordered by node id.</param>
/// <param name="Drivers">Driver rows in this cluster, ordered by name then instance id.</param>
public sealed record HostsClusterGroup(string ClusterId, IReadOnlyList<HostsNodeInfo> Nodes, IReadOnlyList<HostsDriverRow> Drivers);
/// <summary>
/// Pure, DB-agnostic builder for the <c>/hosts</c> "Driver Instances" section. Groups live
/// driver-health snapshots by cluster, attaches each cluster's configured nodes, and enriches
/// every driver row with its configured name + type. Null-safe on all inputs and stable-ordered
/// so the Razor render is deterministic.
/// </summary>
public static class HostsDriverView
{
/// <summary>
/// Builds the per-cluster view model. Clusters are the case-insensitive union of cluster ids
/// seen across <paramref name="snapshots"/> and <paramref name="nodes"/>, ordered
/// case-insensitively. Each group carries its nodes (ordered by node id) and its driver rows
/// (snapshots enriched by joining <paramref name="instances"/> on driver instance id;
/// unmatched ids yield null name/type and an enabled row), ordered by name-then-instance-id.
/// </summary>
/// <param name="snapshots">Live health snapshots; null treated as empty.</param>
/// <param name="nodes">Configured cluster nodes; null treated as empty.</param>
/// <param name="instances">Configured driver instances for enrichment; null treated as empty.</param>
/// <returns>One <see cref="HostsClusterGroup"/> per cluster, ordered by cluster id.</returns>
public static IReadOnlyList<HostsClusterGroup> Build(
IEnumerable<DriverHealthChanged>? snapshots,
IEnumerable<HostsNodeInfo>? nodes,
IEnumerable<HostsDriverInstanceInfo>? instances)
{
var snapList = (snapshots ?? Enumerable.Empty<DriverHealthChanged>()).ToList();
var nodeList = (nodes ?? Enumerable.Empty<HostsNodeInfo>()).ToList();
var instanceList = instances ?? Enumerable.Empty<HostsDriverInstanceInfo>();
// First-wins lookup keyed by driver instance id.
var byInstance = new Dictionary<string, HostsDriverInstanceInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var instance in instanceList)
{
byInstance.TryAdd(instance.DriverInstanceId, instance);
}
var clusterIds = snapList.Select(s => s.ClusterId)
.Concat(nodeList.Select(n => n.ClusterId))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToList();
var groups = new List<HostsClusterGroup>(clusterIds.Count);
foreach (var clusterId in clusterIds)
{
var clusterNodes = nodeList
.Where(n => string.Equals(n.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase))
.OrderBy(n => n.NodeId, StringComparer.OrdinalIgnoreCase)
.ToList();
var clusterDrivers = snapList
.Where(s => string.Equals(s.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase))
.Select(s =>
{
byInstance.TryGetValue(s.DriverInstanceId, out var instance);
return new HostsDriverRow(
s.DriverInstanceId,
instance?.Name,
instance?.DriverType,
s.State,
s.LastSuccessfulReadUtc,
s.LastError,
s.ErrorCount5Min,
s.PublishedUtc,
s.RediscoveryNeededUtc,
s.RediscoveryReason,
s.HostStatuses,
s.SkippedTags,
instance?.Enabled ?? true);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ToList();
groups.Add(new HostsClusterGroup(clusterId, clusterNodes, clusterDrivers));
}
return groups;
}
}