diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md
index 11ca514..d5b91eb 100644
--- a/docs/v2/lmx-followups.md
+++ b/docs/v2/lmx-followups.md
@@ -108,13 +108,30 @@ condition node). Alarm tracking already has its own integration test
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
`IAlarmSource` that's worth its own focused PR.
-## 7. Host-status per-AppEngine granularity → Admin UI dashboard
+## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
-**Status**: PR 13 ships per-platform/per-AppEngine `ScanState` probing; PR 17
-surfaces the resulting `OnHostStatusChanged` events through OPC UA. Admin
-UI doesn't render a per-host dashboard yet.
+**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
+composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
+indexes (per-cluster drill-down on `NodeId`, stale-row detection on
+`LastSeenUtc`).
-**To do**:
-- SignalR hub push of `HostStatusChangedEventArgs` to the Admin UI.
-- Dashboard page showing each tracked host, current state, last transition
- time, failure count.
+**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
+`BackgroundService` in the Server process that walks every registered
+`IHostConnectivityProbe`-capable driver every 10s, calls
+`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
+`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
+groups by cluster, shows four summary cards (Hosts / Running / Stale /
+Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
+operators see crashed Servers without waiting for a state change.
+
+Deferred as follow-ups:
+
+- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
+ sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
+ 10s polling is fine for operator-scale use.
+- Failure-count column — needs the publisher to track a transition history
+ per host, not just current-state.
+- SignalR fan-out to the Admin page (currently the page polls the DB, not
+ a hub). The DB-polled version is fine at current cadence but a hub push
+ would eliminate the 10s race where a new row sits in the DB before the
+ Admin page notices.
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor
index 03540ed..90687dc 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor
@@ -6,6 +6,7 @@
Overview
Fleet status
+ Host status
Clusters
Reservations
Certificates
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
new file mode 100644
index 0000000..d6a3d0a
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
@@ -0,0 +1,160 @@
+@page "/hosts"
+@using Microsoft.EntityFrameworkCore
+@using ZB.MOM.WW.OtOpcUa.Admin.Services
+@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
+@inject IServiceScopeFactory ScopeFactory
+@implements IDisposable
+
+Driver host status
+
+
+
+ @if (_refreshing) { }
+ Refresh
+
+
+ Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
+
+
+
+
+ Each row is one host reported by a driver instance on a server node. Galaxy drivers report
+ per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
+ of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
+ 30s are flagged Stale, which usually means the owning Server process has crashed or lost
+ its DB connection.
+
+
+@if (_rows is null)
+{
+ Loading…
+}
+else if (_rows.Count == 0)
+{
+
+ No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements IHostConnectivityProbe.
+
+}
+else
+{
+
+
+
+
Running
+
@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))
+
+
+
Stale
+
@_rows.Count(HostStatusService.IsStale)
+
+
+
Faulted
+
@_rows.Count(r => r.State == DriverHostState.Faulted)
+
+
+
+ @foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
+ {
+ Cluster: @cluster.Key
+
+
+
+ Node
+ Driver
+ Host
+ State
+ Last transition
+ Last seen
+ Detail
+
+
+
+ @foreach (var r in cluster)
+ {
+
+ @r.NodeId
+ @r.DriverInstanceId
+ @r.HostName
+
+ @r.State
+ @if (HostStatusService.IsStale(r))
+ {
+ Stale
+ }
+
+ @FormatAge(r.StateChangedUtc)
+ @FormatAge(r.LastSeenUtc)
+ @r.Detail
+
+ }
+
+
+ }
+}
+
+@code {
+ // Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
+ // produces stale-looking rows mid-cycle.
+ private const int RefreshIntervalSeconds = 10;
+
+ private List? _rows;
+ private bool _refreshing;
+ private DateTime? _lastRefreshUtc;
+ private Timer? _timer;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await RefreshAsync();
+ _timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
+ state: null,
+ dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
+ period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
+ }
+
+ private async Task RefreshAsync()
+ {
+ if (_refreshing) return;
+ _refreshing = true;
+ try
+ {
+ using var scope = ScopeFactory.CreateScope();
+ var svc = scope.ServiceProvider.GetRequiredService();
+ _rows = (await svc.ListAsync()).ToList();
+ _lastRefreshUtc = DateTime.UtcNow;
+ }
+ finally
+ {
+ _refreshing = false;
+ StateHasChanged();
+ }
+ }
+
+ private static string RowClass(HostStatusRow r) => r.State switch
+ {
+ DriverHostState.Faulted => "table-danger",
+ _ when HostStatusService.IsStale(r) => "table-warning",
+ _ => "",
+ };
+
+ private static string StateBadge(DriverHostState s) => s switch
+ {
+ DriverHostState.Running => "bg-success",
+ DriverHostState.Stopped => "bg-secondary",
+ DriverHostState.Faulted => "bg-danger",
+ _ => "bg-secondary",
+ };
+
+ private static string FormatAge(DateTime t)
+ {
+ var age = DateTime.UtcNow - t;
+ if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
+ if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
+ if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
+ return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
+ }
+
+ public void Dispose() => _timer?.Dispose();
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
index 00c7c25..1214033 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
@@ -47,6 +47,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
new file mode 100644
index 0000000..fcd8ea3
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
@@ -0,0 +1,63 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+///
+/// One row per record, enriched with the owning
+/// ClusterNode.ClusterId when available (left-join). The Admin /hosts page
+/// groups by cluster and renders a per-node → per-driver → per-host tree.
+///
+public sealed record HostStatusRow(
+ string NodeId,
+ string? ClusterId,
+ string DriverInstanceId,
+ string HostName,
+ DriverHostState State,
+ DateTime StateChangedUtc,
+ DateTime LastSeenUtc,
+ string? Detail);
+
+///
+/// Read-side service for the Admin UI's per-host drill-down. Loads
+/// rows (written by the Server process's
+/// HostStatusPublisher ) and left-joins ClusterNode so each row knows which
+/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
+///
+///
+/// The publisher heartbeat is 10s (HostStatusPublisher.HeartbeatInterval ). The
+/// Admin page also polls every ~10s and treats rows with LastSeenUtc older than
+/// StaleThreshold (30s) as stale — covers a missed heartbeat tolerance plus
+/// a generous buffer for clock skew and publisher GC pauses.
+///
+public sealed class HostStatusService(OtOpcUaConfigDbContext db)
+{
+ public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
+
+ public async Task> ListAsync(CancellationToken ct = default)
+ {
+ // LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
+ // been created yet (first-boot bootstrap case — keeps the UI from losing sight of
+ // the reporting server).
+ var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
+ join n in db.ClusterNodes.AsNoTracking()
+ on s.NodeId equals n.NodeId into nodeJoin
+ from n in nodeJoin.DefaultIfEmpty()
+ orderby s.NodeId, s.DriverInstanceId, s.HostName
+ select new HostStatusRow(
+ s.NodeId,
+ n != null ? n.ClusterId : null,
+ s.DriverInstanceId,
+ s.HostName,
+ s.State,
+ s.StateChangedUtc,
+ s.LastSeenUtc,
+ s.Detail)).ToListAsync(ct);
+ return rows;
+ }
+
+ public static bool IsStale(HostStatusRow row) =>
+ DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs b/src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
new file mode 100644
index 0000000..69153ce
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
@@ -0,0 +1,143 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Server;
+
+///
+/// Walks every registered driver once per heartbeat interval, asks each
+/// -capable driver for its current
+/// list, and upserts one
+/// row per (NodeId, DriverInstanceId, HostName) into the
+/// central config DB. Powers the Admin UI's per-host drill-down page (LMX follow-up #7).
+///
+///
+///
+/// Polling rather than event-driven: simpler, and matches the cadence the Admin UI
+/// consumes. An event-subscription optimization (push on OnHostStatusChanged for
+/// immediate reflection) is a straightforward follow-up but adds lifecycle complexity
+/// — drivers can be registered after the publisher starts, and subscribing to each
+/// one's event on register + unsubscribing on unregister requires DriverHost to expose
+/// lifecycle events it doesn't today.
+///
+///
+/// advances every heartbeat so the Admin UI
+/// can flag stale rows from a crashed Server process independent of
+/// — a Faulted publisher that stops heartbeating
+/// stays Faulted in the DB but its LastSeenUtc ages out, which is the signal
+/// operators actually want.
+///
+///
+/// If the DB is unreachable on a given tick, the publisher logs and moves on — it
+/// does not retry or buffer. The next heartbeat picks up the current-state snapshot,
+/// which is more useful than replaying stale transitions after a long outage.
+///
+///
+public sealed class HostStatusPublisher(
+ DriverHost driverHost,
+ NodeOptions nodeOptions,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : BackgroundService
+{
+ internal static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(10);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Wait a short moment at startup so NodeBootstrap's RegisterAsync calls have had a
+ // chance to land. First tick runs immediately after so a freshly-started Server
+ // surfaces its host topology in the Admin UI without waiting a full interval.
+ try { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
+ catch (OperationCanceledException) { return; }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try { await PublishOnceAsync(stoppingToken); }
+ catch (OperationCanceledException) { return; }
+ catch (Exception ex)
+ {
+ // Never take down the Server on a publisher failure. Log and continue —
+ // stale-row detection on the Admin side will surface the outage.
+ logger.LogWarning(ex, "Host-status publisher tick failed — will retry next heartbeat");
+ }
+
+ try { await Task.Delay(HeartbeatInterval, stoppingToken); }
+ catch (OperationCanceledException) { return; }
+ }
+ }
+
+ internal async Task PublishOnceAsync(CancellationToken ct)
+ {
+ var driverIds = driverHost.RegisteredDriverIds;
+ if (driverIds.Count == 0) return;
+
+ var now = DateTime.UtcNow;
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ foreach (var driverId in driverIds)
+ {
+ var driver = driverHost.GetDriver(driverId);
+ if (driver is not IHostConnectivityProbe probe) continue;
+
+ IReadOnlyList statuses;
+ try { statuses = probe.GetHostStatuses(); }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Driver {DriverId} GetHostStatuses threw — skipping this tick", driverId);
+ continue;
+ }
+
+ foreach (var status in statuses)
+ {
+ await UpsertAsync(db, driverId, status, now, ct);
+ }
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+
+ private async Task UpsertAsync(OtOpcUaConfigDbContext db, string driverId,
+ HostConnectivityStatus status, DateTime now, CancellationToken ct)
+ {
+ var mapped = MapState(status.State);
+ var existing = await db.DriverHostStatuses.SingleOrDefaultAsync(r =>
+ r.NodeId == nodeOptions.NodeId
+ && r.DriverInstanceId == driverId
+ && r.HostName == status.HostName, ct);
+
+ if (existing is null)
+ {
+ db.DriverHostStatuses.Add(new DriverHostStatus
+ {
+ NodeId = nodeOptions.NodeId,
+ DriverInstanceId = driverId,
+ HostName = status.HostName,
+ State = mapped,
+ StateChangedUtc = status.LastChangedUtc,
+ LastSeenUtc = now,
+ });
+ return;
+ }
+
+ existing.LastSeenUtc = now;
+ if (existing.State != mapped)
+ {
+ existing.State = mapped;
+ existing.StateChangedUtc = status.LastChangedUtc;
+ }
+ }
+
+ internal static DriverHostState MapState(HostState state) => state switch
+ {
+ HostState.Running => DriverHostState.Running,
+ HostState.Stopped => DriverHostState.Stopped,
+ HostState.Faulted => DriverHostState.Faulted,
+ _ => DriverHostState.Unknown,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
index d228974..78d900d 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
@@ -1,8 +1,10 @@
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
+using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server;
@@ -72,5 +74,11 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
+// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
+// so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick.
+builder.Services.AddDbContext(opt =>
+ opt.UseSqlServer(options.ConfigDbConnectionString));
+builder.Services.AddHostedService();
+
var host = builder.Build();
await host.RunAsync();
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
index 9b4ef26..4db194d 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
@@ -24,6 +24,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
new file mode 100644
index 0000000..b7f4948
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
@@ -0,0 +1,197 @@
+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(o => o.UseSqlServer(_connectionString));
+ _sp = services.BuildServiceProvider();
+
+ using var scope = _sp.CreateScope();
+ scope.ServiceProvider.GetRequiredService().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(),
+ NullLogger.Instance);
+
+ await publisher.PublishOnceAsync(CancellationToken.None);
+
+ using var scope = _sp.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ 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(),
+ NullLogger.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();
+ (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(),
+ NullLogger.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 SingleRowAsync(string node, string driver, string host)
+ {
+ using var scope = _sp.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ 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? 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 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;
+ }
+}