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:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user