Task #154 — driver-diagnostics RPC: HTTP endpoint + Admin client

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.
This commit is contained in:
Joseph Doherty
2026-04-25 01:32:21 -04:00
parent 8004394892
commit 802366c2c6
5 changed files with 327 additions and 1 deletions

View File

@@ -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.
*@
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
<div class="container py-4">
<h1>Modbus auto-prohibitions</h1>
<p class="text-muted">
Driver instance <code>@DriverInstanceId</code>. Live snapshot of coalesced ranges
the planner has learned to read individually (#148 / #150 / #151 / #152).
</p>
<div class="mb-3">
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAsync" disabled="@_loading">
@(_loading ? "Loading…" : "Refresh")
</button>
@if (_lastRefreshed is not null)
{
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
}
</div>
@if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
else if (_response is null)
{
<p class="text-muted">Click <strong>Refresh</strong> to load.</p>
}
else if (_response.Count == 0)
{
<div class="alert alert-success">No auto-prohibitions. The planner is coalescing freely.</div>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Unit</th>
<th>Region</th>
<th>Start</th>
<th>End</th>
<th>Span</th>
<th>Status</th>
<th>Last probed</th>
</tr>
</thead>
<tbody>
@foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress))
{
<tr>
<td><code>@r.UnitId</code></td>
<td><code>@r.Region</code></td>
<td><code>@r.StartAddress</code></td>
<td><code>@r.EndAddress</code></td>
<td>@(r.EndAddress - r.StartAddress + 1)</td>
<td>
@if (r.BisectionPending)
{
<span class="badge bg-warning text-dark">BISECTING</span>
}
else
{
<span class="badge bg-danger">ISOLATED</span>
}
</td>
<td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td>
</tr>
}
</tbody>
</table>
}
</div>
@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";
}
}

View File

@@ -45,6 +45,15 @@ builder.Services.AddScoped<UnsService>();
builder.Services.AddScoped<NamespaceService>();
builder.Services.AddScoped<DriverInstanceService>();
builder.Services.AddScoped<FocasDriverDetailService>();
// #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<DriverDiagnosticsClient>(client =>
{
var baseUrl = builder.Configuration["DriverDiagnostics:ServerBaseUrl"] ?? "http://localhost:4841/";
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<NodeAclService>();
builder.Services.AddScoped<PermissionProbeService>();
builder.Services.AddScoped<AclChangeNotifier>();

View File

@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// #154 — Admin-side client for the Server's driver-diagnostics HTTP endpoints. Wraps
/// <see cref="HttpClient"/> 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 <c>appsettings.json</c> at startup).
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class DriverDiagnosticsClient
{
private readonly HttpClient _http;
public DriverDiagnosticsClient(HttpClient http) => _http = http;
/// <summary>
/// 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.
/// </summary>
public async Task<ModbusAutoProhibitionsResponse?> 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<ModbusAutoProhibitionsResponse>(cancellationToken: ct).ConfigureAwait(false);
}
}
/// <summary>
/// Server response shape for the Modbus auto-prohibition diagnostic. Mirrors the JSON the
/// <c>HealthEndpointsHost</c> serialises; fields are flat strings/numbers so the
/// Admin-side client doesn't take a dependency on the Driver.Modbus assembly's
/// <c>ModbusAutoProhibition</c> record.
/// </summary>
public sealed class ModbusAutoProhibitionsResponse
{
public string DriverInstanceId { get; set; } = string.Empty;
public int Count { get; set; }
public List<ModbusAutoProhibitionRow> 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; }
}

View File

@@ -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;
}
/// <summary>
/// #154 — driver-diagnostics endpoint family. Routes
/// <c>/diagnostics/drivers/{driverId}/modbus/auto-prohibited</c> to the live
/// <see cref="ModbusDriver"/> instance's <see cref="ModbusDriver.GetAutoProhibitedRanges"/>.
/// 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).
/// </summary>
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);

View File

@@ -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<byte[]> 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<byte[]>(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;