feat(health): redesign health dashboard with 4-column layout and new metrics

New fields in SiteHealthReport: NodeHostname, DataConnectionEndpoints
(primary/secondary), DataConnectionTagQuality (good/bad/uncertain),
ParkedMessageCount. New collector methods to populate them.

Health dashboard redesigned to match mockup: Nodes | Data Connections
(with per-connection tag quality) | Instances + S&F Buffers | Error
Counts + Parked Messages. Site names resolved from repository.
This commit is contained in:
Joseph Doherty
2026-03-23 10:44:30 -04:00
parent 5e2a4c9080
commit e84a831a02
5 changed files with 153 additions and 48 deletions

View File

@@ -1,9 +1,12 @@
@page "/monitoring/health"
@attribute [Authorize]
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -56,11 +59,12 @@
</div>
</div>
@* Per-site detail *@
@* Per-site detail cards *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
{
var siteName = GetSiteName(siteId);
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
@if (state.IsOnline)
{
@@ -70,24 +74,34 @@
{
<span class="badge bg-danger me-2">Offline</span>
}
<strong>@siteId</strong>
@if (state.LatestReport?.NodeRole != null)
{
<span class="badge @(state.LatestReport.NodeRole == "Active" ? "bg-primary" : "bg-secondary") ms-2">@state.LatestReport.NodeRole</span>
}
<strong class="fs-5">@siteName (@siteId)</strong>
</div>
<small class="text-muted">
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
</small>
</div>
<div class="card-body">
<div class="card-body p-3">
@if (state.LatestReport != null)
{
var report = state.LatestReport;
<div class="row">
@* Connection Health *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Data Connections</h6>
<div class="row g-3">
@* Column 1: Nodes *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td><span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")">@(state.IsOnline ? "Online" : "Offline")</span></td>
<td><span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")">@(report.NodeRole == "Active" ? "Primary" : "Standby")</span></td>
</tr>
</tbody>
</table>
</div>
@* Column 2: Data Connections *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Data Connections</h6>
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
@@ -96,34 +110,77 @@
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@connName</span>
<span class="badge @GetConnectionHealthBadge(health)">@health</span>
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
<div class="mb-2">
<div class="d-flex justify-content-between">
<strong class="small">@connName</strong>
<span class="small">@(endpoint ?? health.ToString())</span>
</div>
@if (quality != null)
{
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small text-muted py-0">Tags good</td>
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags bad</td>
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags uncertain</td>
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
</tr>
</tbody>
</table>
}
</div>
}
}
</div>
@* Instances *@
<div class="col-md-4">
<h6 class="text-muted small mb-2">Instances</h6>
<div class="d-flex justify-content-between mb-1">
<span class="small">Deployed</span>
<span>@report.DeployedInstanceCount</span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="small">Enabled</span>
<span class="text-success">@report.EnabledInstanceCount</span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="small">Disabled</span>
<span class="text-warning">@report.DisabledInstanceCount</span>
</div>
@* Column 3: Instances + Store-and-Forward *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Deployed</td>
<td class="text-end">@report.DeployedInstanceCount</td>
</tr>
<tr>
<td class="small">Enabled</td>
<td class="text-end text-success">@report.EnabledInstanceCount</td>
</tr>
<tr>
<td class="small">Disabled</td>
<td class="text-end">@report.DisabledInstanceCount</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
</div>
@* Error Counts *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Error Counts</h6>
@* Column 4: Error Counts + Parked Messages *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
@@ -146,24 +203,15 @@
</tr>
</tbody>
</table>
</div>
@* S&F Buffer Depths *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
@if (report.ParkedMessageCount == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
}
</div>
</div>
@@ -180,11 +228,23 @@
@code {
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10;
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
// Load site names for display
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_siteNames = sites.ToDictionary(s => s.SiteIdentifier, s => s.Name);
}
catch
{
// Non-fatal — fall back to showing siteId only
}
RefreshNow();
_refreshTimer = new Timer(_ =>
{
@@ -201,6 +261,11 @@
_siteStates = HealthAggregator.GetAllSiteStates();
}
private string GetSiteName(string siteId)
{
return _siteNames.GetValueOrDefault(siteId, siteId);
}
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
{
ConnectionHealth.Connected => "bg-success",

View File

@@ -15,4 +15,8 @@ public record SiteHealthReport(
int DeployedInstanceCount,
int EnabledInstanceCount,
int DisabledInstanceCount,
string NodeRole = "Unknown");
string NodeRole = "Unknown",
string NodeHostname = "",
IReadOnlyDictionary<string, string>? DataConnectionEndpoints = null,
IReadOnlyDictionary<string, TagQualityCounts>? DataConnectionTagQuality = null,
int ParkedMessageCount = 0);

View File

@@ -0,0 +1,3 @@
namespace ScadaLink.Commons.Messages.Health;
public record TagQualityCounts(int Good, int Bad, int Uncertain);

View File

@@ -15,8 +15,12 @@ public interface ISiteHealthCollector
void UpdateConnectionHealth(string connectionName, ConnectionHealth health);
void RemoveConnection(string connectionName);
void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved);
void UpdateConnectionEndpoint(string connectionName, string endpoint);
void UpdateTagQuality(string connectionName, int good, int bad, int uncertain);
void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths);
void SetInstanceCounts(int deployed, int enabled, int disabled);
void SetParkedMessageCount(int count);
void SetNodeHostname(string hostname);
void SetActiveNode(bool isActive);
bool IsActiveNode { get; }
SiteHealthReport CollectReport(string siteId);

View File

@@ -15,8 +15,12 @@ public class SiteHealthCollector : ISiteHealthCollector
private int _deadLetterCount;
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
private readonly ConcurrentDictionary<string, TagResolutionStatus> _tagResolutionCounts = new();
private readonly ConcurrentDictionary<string, string> _connectionEndpoints = new();
private readonly ConcurrentDictionary<string, TagQualityCounts> _tagQualityCounts = new();
private IReadOnlyDictionary<string, int> _sfBufferDepths = new Dictionary<string, int>();
private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount;
private int _parkedMessageCount;
private volatile string _nodeHostname = "";
private volatile bool _isActiveNode;
/// <summary>
@@ -60,6 +64,8 @@ public class SiteHealthCollector : ISiteHealthCollector
{
_connectionStatuses.TryRemove(connectionName, out _);
_tagResolutionCounts.TryRemove(connectionName, out _);
_connectionEndpoints.TryRemove(connectionName, out _);
_tagQualityCounts.TryRemove(connectionName, out _);
}
/// <summary>
@@ -71,6 +77,23 @@ public class SiteHealthCollector : ISiteHealthCollector
_tagResolutionCounts[connectionName] = new TagResolutionStatus(totalSubscribed, successfullyResolved);
}
public void UpdateConnectionEndpoint(string connectionName, string endpoint)
{
_connectionEndpoints[connectionName] = endpoint;
}
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain)
{
_tagQualityCounts[connectionName] = new TagQualityCounts(good, bad, uncertain);
}
public void SetParkedMessageCount(int count)
{
Interlocked.Exchange(ref _parkedMessageCount, count);
}
public void SetNodeHostname(string hostname) => _nodeHostname = hostname;
/// <summary>
/// Set the current store-and-forward buffer depths snapshot.
/// Called before report collection with data from the S&amp;F service.
@@ -110,6 +133,8 @@ public class SiteHealthCollector : ISiteHealthCollector
// Snapshot current connection and tag resolution state
var connectionStatuses = new Dictionary<string, ConnectionHealth>(_connectionStatuses);
var tagResolution = new Dictionary<string, TagResolutionStatus>(_tagResolutionCounts);
var connectionEndpoints = new Dictionary<string, string>(_connectionEndpoints);
var tagQuality = new Dictionary<string, TagQualityCounts>(_tagQualityCounts);
// Snapshot current S&F buffer depths
var sfBufferDepths = new Dictionary<string, int>(_sfBufferDepths);
@@ -130,6 +155,10 @@ public class SiteHealthCollector : ISiteHealthCollector
DeployedInstanceCount: _deployedInstanceCount,
EnabledInstanceCount: _enabledInstanceCount,
DisabledInstanceCount: _disabledInstanceCount,
NodeRole: nodeRole);
NodeRole: nodeRole,
NodeHostname: _nodeHostname,
DataConnectionEndpoints: connectionEndpoints,
DataConnectionTagQuality: tagQuality,
ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0));
}
}