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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user