@page "/monitoring/health" @attribute [Authorize] @using ScadaLink.CentralUI.Components.Health @using ScadaLink.CentralUI.Services @using ScadaLink.Commons.Types @using ScadaLink.Commons.Types.Enums @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.HealthMonitoring @using ScadaLink.Commons.Messages.Notification @using ScadaLink.Communication @implements IDisposable @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService @inject IAuditLogQueryService AuditLogQueryService

Health Dashboard

Auto-refresh: @(_autoRefreshSeconds)s
@* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
Notification Outbox
View details →

@OutboxTileValue(_outboxKpi.QueueDepth)

Queue Depth

@OutboxTileValue(_outboxKpi.StuckCount)

Stuck

@OutboxTileValue(_outboxKpi.ParkedCount)

Parked
@if (!_outboxKpiAvailable && _outboxKpiError != null) {
Notification Outbox KPIs unavailable: @_outboxKpiError
} @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel (volume / error rate / backlog). Refreshed alongside the site states. *@ @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; // Notification Outbox headline KPIs, refreshed alongside the site states. private NotificationKpiResponse _outboxKpi = new( CorrelationId: string.Empty, Success: false, ErrorMessage: null, QueueDepth: 0, StuckCount: 0, ParkedCount: 0, DeliveredLastInterval: 0, OldestPendingAge: null); private bool _outboxKpiAvailable; private string? _outboxKpiError; // Audit Log (#23) M7 Bundle E — Audit KPI tiles. Volume + error rate come // from a 1h aggregate over the central AuditLog table; backlog sums the // per-site SiteAuditBacklog.PendingCount via the health aggregator. private AuditLogKpiSnapshot? _auditKpi; private bool _auditKpiAvailable; private string? _auditKpiError; 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 } await RefreshNow(); _refreshTimer = new Timer(_ => { InvokeAsync(async () => { await RefreshNow(); StateHasChanged(); }); }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); } private async Task RefreshNow() { _siteStates = HealthAggregator.GetAllSiteStates(); await LoadOutboxKpis(); await LoadAuditKpis(); } private async Task LoadOutboxKpis() { try { var response = await CommunicationService.GetNotificationKpisAsync( new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); if (response.Success) { _outboxKpi = response; _outboxKpiAvailable = true; _outboxKpiError = null; } else { _outboxKpiAvailable = false; _outboxKpiError = response.ErrorMessage ?? "KPI query failed."; } } catch (Exception ex) { _outboxKpiAvailable = false; _outboxKpiError = $"KPI query failed: {ex.Message}"; } } // Tiles show the numeric KPI when available, or an em dash when the outbox // KPI query failed — matching how the page renders other unavailable data. private string OutboxTileValue(int value) => _outboxKpiAvailable ? value.ToString() : "—"; // Audit KPI loader: wraps the service call so a transient DB outage degrades // the three tiles to em dashes with an inline error rather than killing the // dashboard. Mirrors LoadOutboxKpis's error handling shape. private async Task LoadAuditKpis() { try { _auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync(); _auditKpiAvailable = true; _auditKpiError = null; } catch (Exception ex) { _auditKpiAvailable = false; _auditKpiError = $"KPI query failed: {ex.Message}"; } } 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(); } }