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

@@ -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;