@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

Health Dashboard

Auto-refresh: @(_autoRefreshSeconds)s
@if (_siteStates.Count == 0) {
No site health reports received yet.
} else { @* Overview cards *@

@_siteStates.Values.Count(s => s.IsOnline)

Sites Online

@_siteStates.Values.Count(s => !s.IsOnline)

Sites Offline

@_siteStates.Values.Count(SiteHasActiveErrors)

Sites with active errors
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@ @foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key)) { var isCentral = siteId == CentralHealthReportLoop.CentralSiteId; var siteName = isCentral ? "Central Cluster" : GetSiteName(siteId); var detailsCollapseId = $"site-details-{siteId}";
@if (state.IsOnline) { @OnlineGlyph Online } else { @OfflineGlyph Offline } @siteName@(isCentral ? "" : $" ({siteId})")
Last report: | Last heartbeat: | Seq: @state.LastSequenceNumber
@if (state.LatestReport != null) { var report = state.LatestReport;
@* Column 1: Nodes *@
Nodes
@if (report.ClusterNodes is { Count: > 0 }) { @foreach (var node in report.ClusterNodes) { } } else { }
@node.Hostname @(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline") @(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role
@(report.NodeHostname != "" ? report.NodeHostname : "Node") @(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline") @{ var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby"; } @(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel
@* Column 2: Data Connections (collapsible) *@
@if (report.DataConnectionStatuses.Count == 0) { None } else { @foreach (var (connName, health) in report.DataConnectionStatuses) { var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName); var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
@connName @(endpoint ?? health.ToString())
@if (quality != null) {
Tags good @quality.Good.ToString("N0")
Tags bad @quality.Bad.ToString("N0")
Tags uncertain @quality.Uncertain.ToString("N0")
}
} }
@* Column 3: Instances + Store-and-Forward (collapsible) *@
Instances
Deployed @report.DeployedInstanceCount
Enabled @report.EnabledInstanceCount
Disabled @report.DisabledInstanceCount
Store-and-Forward Buffers
@if (report.StoreAndForwardBufferDepths.Count == 0) { Empty } else { @foreach (var (category, depth) in report.StoreAndForwardBufferDepths) {
@category @depth
} }
@* Column 4: Error Counts + Parked Messages (collapsible) *@
Error Counts
Script Errors @report.ScriptErrorCount
Alarm Eval Errors @report.AlarmEvaluationErrorCount
Dead Letters @report.DeadLetterCount
Parked Messages
@if (report.ParkedMessageCount == 0) { Empty } else { @report.ParkedMessageCount }
} else { No report data available. }
} }
@code { // Shape-coded status glyphs to pair with badge colour. private const string OnlineGlyph = "●"; // ● private const string OfflineGlyph = "○"; // ○ private const string PrimaryGlyph = "▲"; // ▲ private const string StandbyGlyph = "△"; // △ private IReadOnlyDictionary _siteStates = new Dictionary(); private Dictionary _siteNames = new(); private Timer? _refreshTimer; private int _autoRefreshSeconds = 10; private static bool SiteHasActiveErrors(SiteHealthState state) { var report = state.LatestReport; if (report == null) return false; return report.ScriptErrorCount > 0 || report.AlarmEvaluationErrorCount > 0 || report.DeadLetterCount > 0; } 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(_ => { InvokeAsync(() => { RefreshNow(); StateHasChanged(); }); }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); } private void RefreshNow() { _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", ConnectionHealth.Connecting => "bg-warning text-dark", ConnectionHealth.Disconnected => "bg-danger", _ => "bg-secondary" }; public void Dispose() { _refreshTimer?.Dispose(); } }