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.
248 lines
10 KiB
C#
248 lines
10 KiB
C#
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
[Trait("Category", "Integration")]
|
|
public sealed class HealthEndpointsHostTests : IAsyncLifetime
|
|
{
|
|
private static int _portCounter = 48500 + Random.Shared.Next(0, 99);
|
|
private readonly int _port = Interlocked.Increment(ref _portCounter);
|
|
private string Prefix => $"http://localhost:{_port}/";
|
|
private readonly DriverHost _driverHost = new();
|
|
private HealthEndpointsHost _host = null!;
|
|
private HttpClient _client = null!;
|
|
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
_client = new HttpClient { BaseAddress = new Uri(Prefix) };
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_client.Dispose();
|
|
if (_host is not null) await _host.DisposeAsync();
|
|
}
|
|
|
|
private HealthEndpointsHost Start(Func<bool>? configDbHealthy = null, Func<bool>? usingStaleConfig = null)
|
|
{
|
|
_host = new HealthEndpointsHost(
|
|
_driverHost,
|
|
NullLogger<HealthEndpointsHost>.Instance,
|
|
configDbHealthy,
|
|
usingStaleConfig,
|
|
prefix: Prefix);
|
|
_host.Start();
|
|
return _host;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Healthz_ReturnsHealthy_EmptyFleet()
|
|
{
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/healthz");
|
|
|
|
response.IsSuccessStatusCode.ShouldBeTrue();
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
|
body.GetProperty("status").GetString().ShouldBe("healthy");
|
|
body.GetProperty("configDbReachable").GetBoolean().ShouldBeTrue();
|
|
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Healthz_StaleConfig_Returns200_WithFlag()
|
|
{
|
|
Start(configDbHealthy: () => false, usingStaleConfig: () => true);
|
|
|
|
var response = await _client.GetAsync("/healthz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
|
body.GetProperty("configDbReachable").GetBoolean().ShouldBeFalse();
|
|
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Healthz_UnreachableConfig_And_NoCache_Returns503()
|
|
{
|
|
Start(configDbHealthy: () => false, usingStaleConfig: () => false);
|
|
|
|
var response = await _client.GetAsync("/healthz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Readyz_EmptyFleet_Is200_Healthy()
|
|
{
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/readyz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
|
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Readyz_WithHealthyDriver_Is200()
|
|
{
|
|
await _driverHost.RegisterAsync(new StubDriver("drv-1", DriverState.Healthy), "{}", CancellationToken.None);
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/readyz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
|
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
|
|
body.GetProperty("drivers").GetArrayLength().ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Readyz_WithFaultedDriver_Is503()
|
|
{
|
|
await _driverHost.RegisterAsync(new StubDriver("dead", DriverState.Faulted), "{}", CancellationToken.None);
|
|
await _driverHost.RegisterAsync(new StubDriver("alive", DriverState.Healthy), "{}", CancellationToken.None);
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/readyz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
|
body.GetProperty("verdict").GetString().ShouldBe("Faulted");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Readyz_WithDegradedDriver_Is200_WithDegradedList()
|
|
{
|
|
await _driverHost.RegisterAsync(new StubDriver("drv-ok", DriverState.Healthy), "{}", CancellationToken.None);
|
|
await _driverHost.RegisterAsync(new StubDriver("drv-deg", DriverState.Degraded), "{}", CancellationToken.None);
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/readyz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
|
body.GetProperty("verdict").GetString().ShouldBe("Degraded");
|
|
body.GetProperty("degradedDrivers").GetArrayLength().ShouldBe(1);
|
|
body.GetProperty("degradedDrivers")[0].GetString().ShouldBe("drv-deg");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Readyz_WithInitializingDriver_Is503()
|
|
{
|
|
await _driverHost.RegisterAsync(new StubDriver("init", DriverState.Initializing), "{}", CancellationToken.None);
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/readyz");
|
|
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unknown_Path_Returns404()
|
|
{
|
|
Start();
|
|
|
|
var response = await _client.GetAsync("/foo");
|
|
|
|
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;
|
|
public StubDriver(string id, DriverState state)
|
|
{
|
|
DriverInstanceId = id;
|
|
_state = state;
|
|
}
|
|
public string DriverInstanceId { get; }
|
|
public string DriverType => "Stub";
|
|
public Task InitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(_state, null, null);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
}
|
|
}
|