diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsLinks.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsLinks.cs new file mode 100644 index 00000000..243a7e69 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsLinks.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients; + +/// +/// Helpers for the per-node diagnostics page (A-F4 / A-F5). /fleet/diagnostics was reachable only +/// by URL; /fleet 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 (DriverInstanceDiagnostics.Name), so the AdminUI joins the name from the ConfigDb. +/// +public static class FleetDiagnosticsLinks +{ + /// The diagnostics page route. + public const string PagePath = "/fleet/diagnostics"; + + /// The diagnostics URL for one node. + /// The node id as /fleet shows it (host:port). + /// A relative URL with the node escaped. + public static string For(string nodeId) => PagePath + "?node=" + Uri.EscapeDataString(nodeId); + + /// Loads the authored display names of the given driver instance ids. + /// The config DB context. + /// The string driver instance ids from the snapshot. + /// Cancellation token. + /// Id → authored name (ordinal-ignore-case keys); ids with no configured row are absent. + public static async Task> LoadDisplayNamesAsync( + OtOpcUaConfigDbContext db, IReadOnlyCollection driverInstanceIds, CancellationToken ct = default) + { + if (driverInstanceIds.Count == 0) return new Dictionary(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(StringComparer.OrdinalIgnoreCase); + foreach (var r in rows) + { + if (!string.IsNullOrWhiteSpace(r.Name)) map[r.DriverInstanceId] = r.Name; + } + + return map; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor index 76cd5db1..dc34c5e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor @@ -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) { - @r.NodeId + + @r.NodeId + @* 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)) + { + diagnostics + } + @foreach (var role in r.Roles) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor index 1b958048..e55791b7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor @@ -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 DbFactory @inject NavigationManager Nav @implements IDisposable @@ -124,10 +128,18 @@ else - @foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) + @foreach (var d in _snapshot.Drivers.OrderBy(d => DisplayNameOf(d), StringComparer.OrdinalIgnoreCase)) { - @d.Name + + @* 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)) + { +
@d.Name
+ } + @(d.DriverInstanceId == Guid.Empty ? "—" : DisplayText.Abbreviate(d.DriverInstanceId.ToString(), 13)) @d.State @d.ConnectedDevices @@ -151,6 +163,7 @@ else private string _selectedNode = ""; private string _typedNode = ""; private NodeDiagnosticsSnapshot? _snapshot; + private IReadOnlyDictionary _displayNames = new Dictionary(); 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> 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(); + } + } + private static string StateChipClass(string state) => state switch { "Healthy" => "chip-ok", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FleetDiagnosticsLinksTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FleetDiagnosticsLinksTests.cs new file mode 100644 index 00000000..d6ab6f73 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FleetDiagnosticsLinksTests.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Clients; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// A-F4 / A-F5: /fleet links to the per-node diagnostics page, which shows authored names. +[Trait("Category", "Unit")] +public sealed class FleetDiagnosticsLinksTests +{ + [Fact] + public void Link_escapes_the_node_id() + => FleetDiagnosticsLinks.For("host-a:4053").ShouldBe("/fleet/diagnostics?node=host-a%3A4053"); + + [Fact] + public async Task Display_names_join_from_the_config_db_and_unknown_ids_are_absent() + { + var name = Guid.NewGuid().ToString("N"); + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options; + await using (var seed = new OtOpcUaConfigDbContext(options)) + { + seed.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "drv-press-1", ClusterId = "MAIN", Name = "Press line Modbus", + DriverType = "Modbus", DriverConfig = "{}", + }); + await seed.SaveChangesAsync(); + } + + await using var db = new OtOpcUaConfigDbContext(options); + var names = await FleetDiagnosticsLinks.LoadDisplayNamesAsync(db, ["drv-press-1", "drv-unknown"]); + + names["DRV-PRESS-1"].ShouldBe("Press line Modbus"); // the returned map is case-insensitive + names.ContainsKey("drv-unknown").ShouldBeFalse(); + + (await FleetDiagnosticsLinks.LoadDisplayNamesAsync(db, [])).ShouldBeEmpty(); + } + + [Fact] + public void Fleet_rows_link_driver_nodes_and_the_page_uses_the_names() + { + AdminUiSource.Read("Components", "Pages", "Fleet.razor") + .ShouldContain("href=\"@FleetDiagnosticsLinks.For(r.NodeId)\""); + var diag = AdminUiSource.Read("Components", "Pages", "FleetDiagnostics.razor"); + diag.ShouldContain("FleetDiagnosticsLinks.LoadDisplayNamesAsync("); + diag.ShouldContain("@DisplayNameOf(d)"); + } +}