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",