feat(health): show all cluster nodes (online/offline, primary/standby) in health dashboard

Add NodeStatus record, IClusterNodeProvider interface, and AkkaClusterNodeProvider
that queries Akka cluster membership for all site-role nodes. HealthReportSender
populates ClusterNodes before each report. UI shows a row per node with
hostname, Online/Offline badge, and Primary/Standby badge. Falls back to
single-node display if ClusterNodes is not populated.
This commit is contained in:
Joseph Doherty
2026-03-23 14:54:59 -04:00
parent 65cc7b69cd
commit 02a7e8abc6
9 changed files with 127 additions and 8 deletions

View File

@@ -18,6 +18,7 @@ public class HealthReportSender : BackgroundService
private readonly ILogger<HealthReportSender> _logger;
private readonly string _siteId;
private readonly StoreAndForwardStorage? _sfStorage;
private readonly IClusterNodeProvider? _clusterNodeProvider;
private long _sequenceNumber;
public HealthReportSender(
@@ -26,7 +27,8 @@ public class HealthReportSender : BackgroundService
IOptions<HealthMonitoringOptions> options,
ILogger<HealthReportSender> logger,
ISiteIdentityProvider siteIdentityProvider,
StoreAndForwardStorage? sfStorage = null)
StoreAndForwardStorage? sfStorage = null,
IClusterNodeProvider? clusterNodeProvider = null)
{
_collector = collector;
_transport = transport;
@@ -34,6 +36,7 @@ public class HealthReportSender : BackgroundService
_logger = logger;
_siteId = siteIdentityProvider.SiteId;
_sfStorage = sfStorage;
_clusterNodeProvider = clusterNodeProvider;
}
/// <summary>
@@ -58,6 +61,15 @@ public class HealthReportSender : BackgroundService
if (!_collector.IsActiveNode)
continue;
if (_clusterNodeProvider != null)
{
try
{
_collector.SetClusterNodes(_clusterNodeProvider.GetClusterNodes());
}
catch { /* Non-fatal */ }
}
if (_sfStorage != null)
{
try

View File

@@ -0,0 +1,12 @@
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Provides cluster node status information for health reporting.
/// Implemented by the Host project which has access to the Akka.NET actor system.
/// </summary>
public interface IClusterNodeProvider
{
IReadOnlyList<NodeStatus> GetClusterNodes();
}

View File

@@ -21,6 +21,7 @@ public interface ISiteHealthCollector
void SetInstanceCounts(int deployed, int enabled, int disabled);
void SetParkedMessageCount(int count);
void SetNodeHostname(string hostname);
void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes);
void SetActiveNode(bool isActive);
bool IsActiveNode { get; }
SiteHealthReport CollectReport(string siteId);

View File

@@ -21,6 +21,7 @@ public class SiteHealthCollector : ISiteHealthCollector
private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount;
private int _parkedMessageCount;
private volatile string _nodeHostname = "";
private volatile IReadOnlyList<Commons.Messages.Health.NodeStatus>? _clusterNodes;
private volatile bool _isActiveNode;
/// <summary>
@@ -94,6 +95,8 @@ public class SiteHealthCollector : ISiteHealthCollector
public void SetNodeHostname(string hostname) => _nodeHostname = hostname;
public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) => _clusterNodes = nodes;
/// <summary>
/// Set the current store-and-forward buffer depths snapshot.
/// Called before report collection with data from the S&amp;F service.
@@ -159,6 +162,7 @@ public class SiteHealthCollector : ISiteHealthCollector
NodeHostname: _nodeHostname,
DataConnectionEndpoints: connectionEndpoints,
DataConnectionTagQuality: tagQuality,
ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0));
ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0),
ClusterNodes: _clusterNodes?.ToList());
}
}