Polling chosen over event-driven for initial scope: simpler, matches Admin UI consumer cadence, avoids DriverHost lifecycle-event plumbing that doesn't exist today. Event-driven push for sub-heartbeat latency is a straightforward follow-up. Admin.Services.HostStatusService left-joins DriverHostStatus against ClusterNode on NodeId so rows persist even when the ClusterNode entry doesn't exist yet (first-boot bootstrap case). StaleThreshold = 30s — covers one missed publisher heartbeat plus a generous buffer for clock skew and GC pauses. Admin Components/Pages/Hosts.razor — FleetAdmin-visible page grouped by cluster (handles the '(unassigned)' case for rows without a matching ClusterNode). Four summary cards (Hosts / Running / Stale / Faulted); per-cluster table with Node / Driver / Host / State + Stale-badge / Last-transition / Last-seen / Detail columns; 10s auto-refresh via IServiceScopeFactory timer pattern matching FleetStatusPoller + Fleet dashboard (PR 27). Row-class highlighting: Faulted → table-danger, Stale → table-warning, else default. State badge maps DriverHostState enum to bootstrap color classes. Sidebar link added between 'Fleet status' and 'Clusters'. Server csproj adds Microsoft.EntityFrameworkCore.SqlServer 10.0.0 + registers OtOpcUaConfigDbContext in Program.cs scoped via NodeOptions.ConfigDbConnectionString (no Admin-style manual SQL raw — the DbContext is the only access path, keeps migrations owner-of-record). Tests — HostStatusPublisherTests (4 new Integration cases, uses per-run throwaway DB matching the FleetStatusPollerTests pattern): publisher upserts one row per host from each probe-capable driver and skips non-probe drivers; second tick advances LastSeenUtc without creating duplicate rows (upsert pattern verified end-to-end); state change between ticks updates State AND StateChangedUtc (datetime2(3) rounds to millisecond precision so comparison uses 1ms tolerance — documented inline); MapState translates every HostState enum member. Server.Tests Integration: 4 new tests pass. Admin build clean, Admin.Tests Unit still 23 / 0. docs/v2/lmx-followups.md item #7 marked DONE with three explicit deferred items (event-driven push, failure-count column, SignalR fan-out). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
9.5 KiB
C#
198 lines
9.5 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Server;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
[Trait("Category", "Integration")]
|
|
public sealed class HostStatusPublisherTests : IDisposable
|
|
{
|
|
private const string DefaultServer = "localhost,14330";
|
|
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
|
|
|
private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}";
|
|
private readonly string _connectionString;
|
|
private readonly ServiceProvider _sp;
|
|
|
|
public HostStatusPublisherTests()
|
|
{
|
|
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
|
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
|
_connectionString =
|
|
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
|
|
_sp = services.BuildServiceProvider();
|
|
|
|
using var scope = _sp.CreateScope();
|
|
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_sp.Dispose();
|
|
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
|
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString);
|
|
conn.Open();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = $@"
|
|
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
|
BEGIN
|
|
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
|
DROP DATABASE [{_databaseName}];
|
|
END";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Publisher_upserts_one_row_per_host_reported_by_each_probe_driver()
|
|
{
|
|
var driverHost = new DriverHost();
|
|
await driverHost.RegisterAsync(new ProbeStubDriver("driver-a",
|
|
new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow),
|
|
new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)),
|
|
"{}", CancellationToken.None);
|
|
await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None);
|
|
|
|
var nodeOptions = NewNodeOptions("node-a");
|
|
var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService<IServiceScopeFactory>(),
|
|
NullLogger<HostStatusPublisher>.Instance);
|
|
|
|
await publisher.PublishOnceAsync(CancellationToken.None);
|
|
|
|
using var scope = _sp.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
|
var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync();
|
|
|
|
rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it");
|
|
rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a");
|
|
rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a");
|
|
rows.ShouldAllBe(r => r.NodeId == "node-a");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows()
|
|
{
|
|
var driver = new ProbeStubDriver("driver-x",
|
|
new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow));
|
|
var driverHost = new DriverHost();
|
|
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
|
|
|
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"),
|
|
_sp.GetRequiredService<IServiceScopeFactory>(),
|
|
NullLogger<HostStatusPublisher>.Instance);
|
|
|
|
await publisher.PublishOnceAsync(CancellationToken.None);
|
|
var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
|
await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances
|
|
await publisher.PublishOnceAsync(CancellationToken.None);
|
|
var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
|
|
|
secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc,
|
|
"heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers");
|
|
|
|
// Still exactly one row — a naive Add-every-tick would have thrown or duplicated.
|
|
using var scope = _sp.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
|
(await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_change_between_ticks_updates_State_and_StateChangedUtc()
|
|
{
|
|
var driver = new ProbeStubDriver("driver-y",
|
|
new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10)));
|
|
var driverHost = new DriverHost();
|
|
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
|
|
|
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"),
|
|
_sp.GetRequiredService<IServiceScopeFactory>(),
|
|
NullLogger<HostStatusPublisher>.Instance);
|
|
|
|
await publisher.PublishOnceAsync(CancellationToken.None);
|
|
var before = await SingleRowAsync("node-y", "driver-y", "HostY");
|
|
|
|
// Swap the driver's reported state to Faulted with a newer transition timestamp.
|
|
var newChange = DateTime.UtcNow;
|
|
driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)];
|
|
await publisher.PublishOnceAsync(CancellationToken.None);
|
|
|
|
var after = await SingleRowAsync("node-y", "driver-y", "HostY");
|
|
after.State.ShouldBe(DriverHostState.Faulted);
|
|
// datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks,
|
|
// so the stored value rounds down. Compare at millisecond granularity to stay clean.
|
|
after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1));
|
|
after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc,
|
|
"StateChangedUtc must advance when the state actually changed");
|
|
before.State.ShouldBe(DriverHostState.Running);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapState_translates_every_HostState_member()
|
|
{
|
|
HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running);
|
|
HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped);
|
|
HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted);
|
|
HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown);
|
|
}
|
|
|
|
private async Task<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
|
|
{
|
|
using var scope = _sp.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
|
return await db.DriverHostStatuses.AsNoTracking()
|
|
.SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host);
|
|
}
|
|
|
|
private static NodeOptions NewNodeOptions(string nodeId) => new()
|
|
{
|
|
NodeId = nodeId,
|
|
ClusterId = "cluster-t",
|
|
ConfigDbConnectionString = "unused-publisher-gets-db-from-scope",
|
|
};
|
|
|
|
private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial)
|
|
: IDriver, IHostConnectivityProbe
|
|
{
|
|
public HostConnectivityStatus[] Statuses { get; set; } = initial;
|
|
public string DriverInstanceId => id;
|
|
public string DriverType => "ProbeStub";
|
|
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => Statuses;
|
|
|
|
// Keeps the compiler happy — event is part of the interface contract even if unused here.
|
|
internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e);
|
|
}
|
|
|
|
private sealed class NonProbeStubDriver(string id) : IDriver
|
|
{
|
|
public string DriverInstanceId => id;
|
|
public string DriverType => "NonProbeStub";
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
}
|
|
}
|