feat(adminui): link /fleet rows to node diagnostics; show authored driver names (A-F4, A-F5)

/fleet/diagnostics was reachable only by URL: each driver-role row on /fleet now
links to it (FleetDiagnosticsLinks.For escapes the host:port node id). The
diagnostics table showed the string instance id because the wire carries no
display name; the page now joins the authored DriverInstances.Name from the
ConfigDb (enrichment only - a failed lookup falls back to the id) and keeps the
id beneath the name.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:59:32 -04:00
parent f12573d3fa
commit fc3178cca8
4 changed files with 143 additions and 4 deletions
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// Helpers for the per-node diagnostics page (A-F4 / A-F5). <c>/fleet/diagnostics</c> was reachable only
/// by URL; <c>/fleet</c> now links each driver node's row to it. The page also showed the driver's
/// string instance id where the operator expects its authored name — the diagnostics wire carries only
/// the id (<c>DriverInstanceDiagnostics.Name</c>), so the AdminUI joins the name from the ConfigDb.
/// </summary>
public static class FleetDiagnosticsLinks
{
/// <summary>The diagnostics page route.</summary>
public const string PagePath = "/fleet/diagnostics";
/// <summary>The diagnostics URL for one node.</summary>
/// <param name="nodeId">The node id as <c>/fleet</c> shows it (<c>host:port</c>).</param>
/// <returns>A relative URL with the node escaped.</returns>
public static string For(string nodeId) => PagePath + "?node=" + Uri.EscapeDataString(nodeId);
/// <summary>Loads the authored display names of the given driver instance ids.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="driverInstanceIds">The string driver instance ids from the snapshot.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Id → authored name (ordinal-ignore-case keys); ids with no configured row are absent.</returns>
public static async Task<IReadOnlyDictionary<string, string>> LoadDisplayNamesAsync(
OtOpcUaConfigDbContext db, IReadOnlyCollection<string> driverInstanceIds, CancellationToken ct = default)
{
if (driverInstanceIds.Count == 0) return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var ids = driverInstanceIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var rows = await db.DriverInstances.AsNoTracking()
.Where(d => ids.Contains(d.DriverInstanceId))
.Select(d => new { d.DriverInstanceId, d.Name })
.ToListAsync(ct);
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in rows)
{
if (!string.IsNullOrWhiteSpace(r.Name)) map[r.DriverInstanceId] = r.Name;
}
return map;
}
}
@@ -6,6 +6,7 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.Cluster
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -81,7 +82,15 @@ else
@foreach (var r in _rows)
{
<tr>
<td><span class="mono">@r.NodeId</span></td>
<td>
<span class="mono">@r.NodeId</span>
@* A-F4: the per-node diagnostics page was reachable only by URL. Driver nodes
answer it (the snapshot comes from their DriverHostActor). *@
@if (r.Roles.Contains(RoleParser.Driver))
{
<a class="small ms-2" href="@FleetDiagnosticsLinks.For(r.NodeId)">diagnostics</a>
}
</td>
<td>
@foreach (var role in r.Roles)
{
@@ -7,11 +7,15 @@
a driver-role member from the selector. *@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.Cluster
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration
@inject IClusterRoleInfo Cluster
@inject IFleetDiagnosticsClient Diagnostics
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@implements IDisposable
@@ -124,10 +128,18 @@ else
</tr>
</thead>
<tbody>
@foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
@foreach (var d in _snapshot.Drivers.OrderBy(d => DisplayNameOf(d), StringComparer.OrdinalIgnoreCase))
{
<tr>
<td>@d.Name</td>
<td>
@* A-F5: the wire carries the string instance id; show the authored name
(joined from the ConfigDb) with the id beneath it. *@
@DisplayNameOf(d)
@if (!string.Equals(DisplayNameOf(d), d.Name, StringComparison.Ordinal))
{
<div class="mono small text-muted">@d.Name</div>
}
</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>
@@ -151,6 +163,7 @@ else
private string _selectedNode = "";
private string _typedNode = "";
private NodeDiagnosticsSnapshot? _snapshot;
private IReadOnlyDictionary<string, string> _displayNames = new Dictionary<string, string>();
private bool _loading;
private DateTime? _fetchedUtc;
@@ -196,7 +209,7 @@ else
private void Navigate(string node) =>
Nav.NavigateTo(string.IsNullOrEmpty(node)
? "/fleet/diagnostics"
: "/fleet/diagnostics?node=" + Uri.EscapeDataString(node));
: FleetDiagnosticsLinks.For(node));
private async Task RefreshAsync() => await LoadAsync();
@@ -209,6 +222,7 @@ else
// 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;
_displayNames = await LoadDisplayNamesAsync(_snapshot);
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
@@ -221,6 +235,25 @@ else
}
}
// The authored name for a driver row, else its id (no ConfigDb row, or the name lookup failed).
private string DisplayNameOf(DriverInstanceDiagnostics d) =>
_displayNames.TryGetValue(d.Name, out var name) ? name : d.Name;
private async Task<IReadOnlyDictionary<string, string>> LoadDisplayNamesAsync(NodeDiagnosticsSnapshot snapshot)
{
try
{
await using var db = await DbFactory.CreateDbContextAsync(_cts.Token);
return await FleetDiagnosticsLinks.LoadDisplayNamesAsync(
db, snapshot.Drivers.Select(d => d.Name).ToList(), _cts.Token);
}
catch (Exception ex) when (ex is not OperationCanceledException and not ObjectDisposedException)
{
// Names are enrichment only: a ConfigDb hiccup falls back to the ids, never fails the page.
return new Dictionary<string, string>();
}
}
private static string StateChipClass(string state) => state switch
{
"Healthy" => "chip-ok",