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",
|
||||
|
||||
Reference in New Issue
Block a user