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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ScadaLink.Commons.Messages.Health;
|
||||
|
||||
public record TagQualityCounts(int Good, int Bad, int Uncertain);
|
||||
@@ -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);
|
||||
|
||||
@@ -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&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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user