From 802366c2c60c818a002e8747094a0dc6da360688 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 01:32:21 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#154=20=E2=80=94=20driver-diagnostics=20?= =?UTF-8?q?RPC:=20HTTP=20endpoint=20+=20Admin=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for surfacing per-driver runtime state from the Server process to the Admin UI. #152 shipped GetAutoProhibitedRanges() as an in-process accessor; #154 makes it reachable across processes. Server side (HealthEndpointsHost): - New URL family: /diagnostics/drivers/{driverInstanceId}/{driverType}/{topic} - First wired topic: /diagnostics/drivers/{id}/modbus/auto-prohibited - Driver-agnostic at the URL level — future driver types add their own segments[3] cases (e.g. /diagnostics/drivers/{id}/s7/dropped-pdus). - 404 when the driver instance doesn't exist; 400 when the driver exists but isn't a Modbus driver (the per-type endpoint is wrong for this row). - Response shape is flat JSON (unitId / region / startAddress / endAddress / lastProbedUtc / bisectionPending) so consumers don't have to reference the Driver.Modbus assembly's ModbusAutoProhibition record. - Re-uses the existing HttpListener bound to localhost:4841 — same auth / reachability story as /healthz and /readyz. Admin side: - DriverDiagnosticsClient (Services/) — HttpClient wrapper that fetches the per-driver Modbus prohibition list. Returns null on 404/400 (driver missing or wrong type); throws on transport failures. - ModbusAutoProhibitionsResponse + ModbusAutoProhibitionRow flat DTOs — client doesn't take a dep on Driver.Modbus. - ModbusDiagnostics.razor at /modbus/diagnostics/{driverInstanceId} — table view with BISECTING (warning yellow) / ISOLATED (danger red) badges, relative timestamps (e.g. "5m ago"), Refresh button. Errors surface inline rather than swallowing. - HttpClient registration in Program.cs reads DriverDiagnostics:ServerBaseUrl from appsettings.json (default http://localhost:4841/ for same-host deployments). Tests (3 new in HealthEndpointsHostTests): - Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver — registers a Modbus driver with a programmable transport that protects register 102, records the prohibition via a coalesced ReadAsync, hits the endpoint, asserts the returned JSON matches (unitId / region / start / end / pending). - Diagnostics_404_When_Driver_Not_Found - Diagnostics_400_When_Driver_Is_Wrong_Type Architecture note: the Admin-side bUnit-style component test isn't included because Admin.Tests doesn't have bUnit set up. The DriverDiagnosticsClient is unit-testable on its own with a mock HandlerStub if needed — left as a follow-up alongside the broader bUnit setup task. The diagnostic page is now reachable at /modbus/diagnostics/{driverId} from any Admin instance pointing at a Server endpoint URL. Future driver types (S7, AbCip) plug into the same channel by adding their own URL segments in HealthEndpointsHost.WriteDriverDiagnosticsAsync. --- .../Pages/Modbus/ModbusDiagnostics.razor | 120 ++++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 9 ++ .../Services/DriverDiagnosticsClient.cs | 61 +++++++++ .../Observability/HealthEndpointsHost.cs | 68 +++++++++- .../HealthEndpointsHostTests.cs | 70 ++++++++++ 5 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverDiagnosticsClient.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor new file mode 100644 index 0000000..7a05d59 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor @@ -0,0 +1,120 @@ +@page "/modbus/diagnostics/{DriverInstanceId}" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@inject DriverDiagnosticsClient Diagnostics + +@* + #154 — operator-facing view of the Server's auto-prohibition state for a Modbus driver. + Fetches via DriverDiagnosticsClient (HttpClient against the Server's HealthEndpointsHost). + Refreshes on demand; auto-refresh is a future task once a SignalR diag channel exists. +*@ + +Modbus diagnostics — @DriverInstanceId + +
+

Modbus auto-prohibitions

+

+ Driver instance @DriverInstanceId. Live snapshot of coalesced ranges + the planner has learned to read individually (#148 / #150 / #151 / #152). +

+ +
+ + @if (_lastRefreshed is not null) + { + Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss") + } +
+ + @if (_error is not null) + { +
@_error
+ } + else if (_response is null) + { +

Click Refresh to load.

+ } + else if (_response.Count == 0) + { +
No auto-prohibitions. The planner is coalescing freely.
+ } + else + { + + + + + + + + + + + + + + @foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress)) + { + + + + + + + + + + } + +
UnitRegionStartEndSpanStatusLast probed
@r.UnitId@r.Region@r.StartAddress@r.EndAddress@(r.EndAddress - r.StartAddress + 1) + @if (r.BisectionPending) + { + BISECTING + } + else + { + ISOLATED + } + @FormatTimeSince(r.LastProbedUtc)
+ } +
+ +@code { + [Parameter] public string DriverInstanceId { get; set; } = string.Empty; + + private ModbusAutoProhibitionsResponse? _response; + private string? _error; + private bool _loading; + private DateTime? _lastRefreshed; + + private async Task LoadAsync() + { + _loading = true; + _error = null; + try + { + _response = await Diagnostics.GetModbusAutoProhibitedRangesAsync(DriverInstanceId); + _lastRefreshed = DateTime.UtcNow; + if (_response is null) + _error = $"Server reports driver '{DriverInstanceId}' is not present or is not a Modbus driver."; + } + catch (Exception ex) + { + _error = $"Fetch failed: {ex.Message}"; + } + finally + { + _loading = false; + } + } + + private static string FormatTimeSince(DateTime utc) + { + var span = DateTime.UtcNow - utc; + if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds}s ago"; + if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes}m ago"; + if (span.TotalHours < 24) return $"{(int)span.TotalHours}h ago"; + return $"{(int)span.TotalDays}d ago"; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 2b3047c..6277f22 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -45,6 +45,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + +// #154 — Server diagnostics client. Default base URL points at the same machine's +// HealthEndpointsHost (loopback :4841); deployments with remote Servers set +// "DriverDiagnostics:ServerBaseUrl" in appsettings.json. +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["DriverDiagnostics:ServerBaseUrl"] ?? "http://localhost:4841/"; + client.BaseAddress = new Uri(baseUrl); +}); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverDiagnosticsClient.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverDiagnosticsClient.cs new file mode 100644 index 0000000..6789d66 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverDiagnosticsClient.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// #154 — Admin-side client for the Server's driver-diagnostics HTTP endpoints. Wraps +/// so Blazor pages can fetch per-driver runtime state from a +/// remote Server process. The base URL is configured at registration time +/// (typically read from appsettings.json at startup). +/// +/// +/// One client instance per Server endpoint. Multi-server deployments register multiple +/// keyed clients. Errors propagate as exceptions; pages catch and surface to the +/// operator rather than swallowing. +/// +public sealed class DriverDiagnosticsClient +{ + private readonly HttpClient _http; + + public DriverDiagnosticsClient(HttpClient http) => _http = http; + + /// + /// Fetch the current Modbus auto-prohibition list for the named driver instance. + /// Returns null when the Server reports the driver doesn't exist or isn't a Modbus + /// driver. Throws on transport / serialization failures. + /// + public async Task GetModbusAutoProhibitedRangesAsync( + string driverInstanceId, CancellationToken ct = default) + { + var resp = await _http.GetAsync( + $"/diagnostics/drivers/{Uri.EscapeDataString(driverInstanceId)}/modbus/auto-prohibited", ct) + .ConfigureAwait(false); + + if (resp.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.BadRequest) + return null; + + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false); + } +} + +/// +/// Server response shape for the Modbus auto-prohibition diagnostic. Mirrors the JSON the +/// HealthEndpointsHost serialises; fields are flat strings/numbers so the +/// Admin-side client doesn't take a dependency on the Driver.Modbus assembly's +/// ModbusAutoProhibition record. +/// +public sealed class ModbusAutoProhibitionsResponse +{ + public string DriverInstanceId { get; set; } = string.Empty; + public int Count { get; set; } + public List Ranges { get; set; } = new(); +} + +public sealed class ModbusAutoProhibitionRow +{ + public byte UnitId { get; set; } + public string Region { get; set; } = string.Empty; + public ushort StartAddress { get; set; } + public ushort EndAddress { get; set; } + public DateTime LastProbedUtc { get; set; } + public bool BisectionPending { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Observability/HealthEndpointsHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Observability/HealthEndpointsHost.cs index 9b7f8c0..d320468 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Observability/HealthEndpointsHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Observability/HealthEndpointsHost.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Observability; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Server.Observability; @@ -85,7 +86,14 @@ public sealed class HealthEndpointsHost : IAsyncDisposable await WriteReadyzAsync(ctx).ConfigureAwait(false); break; default: - ctx.Response.StatusCode = 404; + // #154 — driver-diagnostics path family. URL shape: + // /diagnostics/drivers/{driverInstanceId}/modbus/auto-prohibited + // Driver-agnostic at the URL level so future driver types (S7, AbCip, + // FOCAS) can add their own per-type subpaths. + if (path.StartsWith("/diagnostics/drivers/", StringComparison.Ordinal)) + await WriteDriverDiagnosticsAsync(ctx, path).ConfigureAwait(false); + else + ctx.Response.StatusCode = 404; break; } } @@ -157,6 +165,64 @@ public sealed class HealthEndpointsHost : IAsyncDisposable return list; } + /// + /// #154 — driver-diagnostics endpoint family. Routes + /// /diagnostics/drivers/{driverId}/modbus/auto-prohibited to the live + /// instance's . + /// 404 when the driver instance doesn't exist; 400 when it exists but isn't a Modbus + /// driver (the per-type endpoint is wrong for this row). + /// + private async Task WriteDriverDiagnosticsAsync(HttpListenerContext ctx, string path) + { + // Path shape: /diagnostics/drivers/{id}/modbus/auto-prohibited + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 4 || segments[0] != "diagnostics" || segments[1] != "drivers") + { + ctx.Response.StatusCode = 404; + return; + } + + var driverId = segments[2]; + var driver = _driverHost.GetDriver(driverId); + if (driver is null) + { + ctx.Response.StatusCode = 404; + await WriteBodyAsync(ctx, JsonSerializer.Serialize(new { error = $"Driver '{driverId}' not found" })).ConfigureAwait(false); + return; + } + + // Per-driver-type subpath dispatch. Today only Modbus is wired; future drivers add + // their own segments[3] cases. + if (segments.Length >= 5 && segments[3] == "modbus" && segments[4] == "auto-prohibited") + { + if (driver is not ModbusDriver modbus) + { + ctx.Response.StatusCode = 400; + await WriteBodyAsync(ctx, JsonSerializer.Serialize(new { error = $"Driver '{driverId}' is not a Modbus driver (type: {driver.DriverType})" })).ConfigureAwait(false); + return; + } + var ranges = modbus.GetAutoProhibitedRanges(); + ctx.Response.StatusCode = 200; + await WriteBodyAsync(ctx, JsonSerializer.Serialize(new + { + driverInstanceId = driverId, + count = ranges.Count, + ranges = ranges.Select(r => new + { + unitId = r.UnitId, + region = r.Region.ToString(), + startAddress = r.StartAddress, + endAddress = r.EndAddress, + lastProbedUtc = r.LastProbedUtc, + bisectionPending = r.BisectionPending, + }).ToArray(), + })).ConfigureAwait(false); + return; + } + + ctx.Response.StatusCode = 404; + } + private static async Task WriteBodyAsync(HttpListenerContext ctx, string body) { var bytes = Encoding.UTF8.GetBytes(body); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HealthEndpointsHostTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HealthEndpointsHostTests.cs index 70d05d9..257f54c 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HealthEndpointsHostTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HealthEndpointsHostTests.cs @@ -157,6 +157,76 @@ public sealed class HealthEndpointsHostTests : IAsyncLifetime response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound); } + // ===== #154 — driver-diagnostics endpoint ===== + + [Fact] + public async Task Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver() + { + // Bring up a Modbus driver with a programmable transport that protects register 102, + // record one prohibition, then hit /diagnostics/drivers/{id}/modbus/auto-prohibited. + var fake = new ModbusDriverDiagnosticsTransport { ProtectedAddress = 102 }; + var t1 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition( + "T1", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 100, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16); + var t2 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition( + "T2", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 102, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16); + var opts = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverOptions + { + Host = "f", Tags = [t1, t2], MaxReadGap = 5, + Probe = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusProbeOptions { Enabled = false }, + }; + var driver = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriver(opts, "diag-mb", _ => fake); + await _driverHost.RegisterAsync(driver, "{}", CancellationToken.None); + await driver.ReadAsync(["T1", "T2"], CancellationToken.None); + + Start(); + + var response = await _client.GetAsync("/diagnostics/drivers/diag-mb/modbus/auto-prohibited"); + + response.IsSuccessStatusCode.ShouldBeTrue(); + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + body.GetProperty("driverInstanceId").GetString().ShouldBe("diag-mb"); + body.GetProperty("count").GetInt32().ShouldBe(1); + var first = body.GetProperty("ranges")[0]; + first.GetProperty("startAddress").GetInt32().ShouldBe(100); + first.GetProperty("endAddress").GetInt32().ShouldBe(102); + first.GetProperty("region").GetString().ShouldBe("HoldingRegisters"); + first.GetProperty("bisectionPending").GetBoolean().ShouldBeTrue(); + } + + [Fact] + public async Task Diagnostics_404_When_Driver_Not_Found() + { + Start(); + var response = await _client.GetAsync("/diagnostics/drivers/no-such/modbus/auto-prohibited"); + response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound); + } + + [Fact] + public async Task Diagnostics_400_When_Driver_Is_Wrong_Type() + { + await _driverHost.RegisterAsync(new StubDriver("not-modbus", DriverState.Healthy), "{}", CancellationToken.None); + Start(); + var response = await _client.GetAsync("/diagnostics/drivers/not-modbus/modbus/auto-prohibited"); + response.StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest); + } + + private sealed class ModbusDriverDiagnosticsTransport : ZB.MOM.WW.OtOpcUa.Driver.Modbus.IModbusTransport + { + public ushort ProtectedAddress { get; set; } = 102; + public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; + public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + { + var addr = (ushort)((pdu[1] << 8) | pdu[2]); + var qty = (ushort)((pdu[3] << 8) | pdu[4]); + if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty) + return Task.FromException(new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusException(0x03, 0x02, "IllegalDataAddress")); + var resp = new byte[2 + qty * 2]; + resp[0] = pdu[0]; resp[1] = (byte)(qty * 2); + return Task.FromResult(resp); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + private sealed class StubDriver : IDriver { private readonly DriverState _state;