@page "/monitoring/health" @attribute [Authorize] @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Types @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.HealthMonitoring @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit @using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit @using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi @using ZB.MOM.WW.ScadaBridge.Communication @implements IDisposable @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository @inject IKpiSnapshotCache KpiSnapshots @inject IKpiHistoryQueryService KpiHistory @inject Microsoft.Extensions.Options.IOptions HealthOptions

Health Dashboard

Auto-refresh: @(_autoRefreshSeconds)s
@* Headline KPI groups. Each is a self-contained panel, so each gets its own card boundary — the same treatment the Site Health Trends group below already uses, including tiles-that-are-cards nested in a card body (Trends does exactly that with KpiTrendChart). The two component-rendered groups own their heading and "View details" link internally, so the Notification Outbox group keeps its heading inside the card body too rather than promoting it to a card-header: uniformity across the panels beats matching the Trends card, whose card-header exists to carry the site selector and window toggle. card-body pb-0 lets each tile row's own mb-3 supply the bottom padding instead of doubling it. *@ @* 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
}
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel (buffered / stuck / parked). Refreshed alongside the site states. *@
@* Three KPI tiles for the Audit channel (volume / error rate / backlog). Refreshed alongside the site states. *@
@* Site Health Trends (M6) — per-site Site Health KPI history. Loads on a separate path from the 10s tile-refresh timer so a trend-query fault can never disturb the live dashboard or its polling loop. The site selector reuses the site keys already loaded into _siteStates; the window toggle drives the time range. Both re-query independently. *@
Site Health Trends
@if (_trendSiteKeys.Count > 0) { @* OnTrendSiteChangedAsync is async — Blazor wraps it in an EventCallback so the returned Task IS awaited; keep the Async signature, do not change it to a void/fire-and-forget handler. This comment MUST stay OUTSIDE the @foreach (var key in _trendSiteKeys) { } }
@if (_trendSiteKeys.Count == 0) { No sites available for trends yet. } else {
@* Register row 33. The point of charting the backlog is retrospective: the daily purge burst is expected, so what an operator needs is to see whether a past spike DRAINED — which a point-in-time tile can never answer. An unreplicated site emits no samples, so this chart is legitimately empty there rather than a flat, misleading zero line. *@
}
@if (_siteStates.Count == 0) {
No site health reports received yet.
} else { @* Overview cards — the fourth headline tile row, so it takes the same card boundary as the three above it. It was also the only tile row on the page with no heading at all; it now carries one for the same reason. *@
Sites

@_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}";
@* Display name and machine id are two different things and are read for two different reasons, so they get two lines: chips + name on the first, the site identifier on its own beneath it. Reference shape: Pages/Admin/Sites.razor (card title + monospace identifier). *@
@if (state.IsOnline) { @OnlineGlyph Online } else { @OfflineGlyph Offline } @siteName @if (state.IsOnline && state.IsMetricsStale) { Metrics stale } @if (IsLocalDbBacklogSustained(state)) { Replication backlog } @if (IsStartupReconcilerMissing(state)) { No self-heal } @if (HasSecretMirrorFailures(state)) { Secret mirror failing } @if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt) { offline since @changedAt.ToString("u") }
@if (!isCentral) {
@siteId
}
@* Admin-only; the control gates itself, and disables when the pair has no online standby to take over. Central acts on the local cluster; a site is asked over the command/control relay. *@ Last report: | Last heartbeat: | Seq: @state.LastSequenceNumber
@if (state.LatestReport != null) { var report = state.LatestReport; @if (HasSecretMirrorFailures(state)) { @* Review finding M1: every site node's deployed-secrets mirror failures, the standby's included (carried on the active node's report). *@ }
@* Each logical group below sits in its own card, with the group's head promoted to a real card-header, instead of h6 rules stacked inside one undifferentiated body. Columns 3 and 4 keep their existing single collapse region (and its id) spanning both of their sub-sections: the disclosure toggle IS the card-header and the two sub-heads live inside the one card body. *@ @* Column 1: Nodes *@
Nodes (@NodeRowCount(state))
@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); var endpointText = endpoint ?? health.ToString();
@* Both halves are site-supplied strings — a long connection name or endpoint URL would otherwise wrap this flex row into several lines. Clip both, full value on the title. *@
@connName @endpointText
@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 (@report.StoreAndForwardBufferDepths.Count)
@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 = "△"; // △ // Human-friendly rendering of the configured metrics-stale window for the // "Metrics stale" badge tooltip (e.g. "2 minutes"). private string StaleTimeoutDisplay => FormatDuration(HealthOptions.Value.MetricsStaleTimeout); // Human-friendly rendering of the configured sustained-backlog dwell for the // "Replication backlog" badge tooltip (e.g. "15 minutes"). private string BacklogSustainedDisplay => FormatDuration(HealthOptions.Value.LocalDbBacklogSustainedFor); // Register row 33: the sustained LocalDb replication backlog badge. // // Reads the anchor the aggregator maintains (LocalDbBacklogAboveSince) rather than the // instantaneous backlog on the report, because a bare "backlog > threshold" badge would // fire on EVERY replicated site EVERY day: the site_events retention purge is CDC-captured // like any other write, so the backlog jumps by the deleted batch size and then drains. // Healthy is "the backlog comes back down"; only a dwell can express that. // // A site that reports no backlog at all (unreplicated — rig site-b/site-c — or a failed // poll) never gets an anchor, so it can never be badged. That is the whole null-semantics // requirement: null means "no reading", not zero. private bool IsLocalDbBacklogSustained(SiteHealthState state) => state.IsOnline && state.LocalDbBacklogAboveSince is { } since && DateTimeOffset.UtcNow - since >= HealthOptions.Value.LocalDbBacklogSustainedFor; // Register row 73: the "No self-heal" badge. // // Warning-level treatment, deliberately not an error and never an offline-style state: // the node still receives config through LocalDb replication while it is up, so what it // has lost is recovery from a deploy that landed while it was DOWN. That is degraded, // not down — which is exactly what a warning badge says. // // Tri-state, and only the explicit false is badged. Null means "not reported": the // central self-report row (where a site reconciler is meaningless) and any site node on // a pre-row-73 build both send null, and badging those would fire on every site the // moment central is upgraded ahead of the sites. Offline sites are skipped for the same // reason the backlog badge skips them — the Offline badge is the signal that matters. private bool IsStartupReconcilerMissing(SiteHealthState state) => state.IsOnline && state.LatestReport?.StartupReconcilerActive == false; // Review finding M1: warn when any site node (the standby included) reports deployed // secrets it cannot apply/decrypt. Null ("no data": central self-report, pre-M1 site) and // nodes with an empty failure list are healthy. Offline sites are skipped like the other // warning badges — the Offline badge is the signal that matters there. private static bool HasSecretMirrorFailures(SiteHealthState state) => state.IsOnline && state.LatestReport?.SecretMirrorStatuses is { } statuses && statuses.Any(n => n.Failures.Count > 0); // Online central nodes, from the same ClusterNodes list the Nodes column renders. // Fewer than two means no standby, which disables the manual-failover control. This is // a display-side guard only — AkkaManualFailoverService re-checks against live cluster // membership, which is the authoritative answer. private static int OnlineNodeCount(SiteHealthState state) => state.LatestReport?.ClusterNodes is { Count: > 0 } nodes ? nodes.Count(n => n.IsOnline) : (state.IsOnline ? 1 : 0); // Rows the Nodes group actually renders: the reported cluster-node list when the // site sends one, otherwise the single synthesized fallback row. Shown in that // group's card-header so the count is legible without reading the table — the same // metadata-in-the-header treatment the Data Connections group already had inline. private static int NodeRowCount(SiteHealthState state) => state.LatestReport?.ClusterNodes is { Count: > 0 } nodes ? nodes.Count : 1; private static string FormatDuration(TimeSpan span) => span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes)) ? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}" : $"{span.TotalSeconds:0} seconds"; private IReadOnlyDictionary _siteStates = new Dictionary(); private Dictionary _siteNames = new(); private Timer? _refreshTimer; private readonly ZB.MOM.WW.ScadaBridge.CentralUI.Services.PollGate _pollGate = new(); 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 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; // Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts // from the central SiteCalls table, fetched alongside the site states. The // SiteCallKpiResponse message doubles as the snapshot the tile takes. private SiteCallKpiResponse? _siteCallKpi; private bool _siteCallKpiAvailable; private string? _siteCallKpiError; // Per-node Site Call KPI breakdown (M5.2 per-node stuck-count KPIs). // Passed to SiteCallKpiTiles as an optional sub-table. private IReadOnlyList _siteCallNodeKpis = Array.Empty(); private bool _siteCallNodeKpiAvailable; // ── Site Health Trends (M6) ─────────────────────────────────────────────── // Per-site Site Health KPI history, loaded on a path entirely separate from // the 10s tile-refresh timer (LoadSiteHealthTrendsAsync, never called from // the timer tick). The site keys are a snapshot of the dashboard's site set, // captured each time trends load so the selector mirrors the live cards. // Window in hours: 24h (default) or 168h (7d). Changing the selected site OR // the window re-queries. Each metric chart carries its own availability + // error so one failed GetSeriesAsync degrades a single chart, never the // dashboard. private IReadOnlyList _trendSiteKeys = Array.Empty(); private string? _trendSiteId; private int _trendWindowHours = 24; private bool _trendsLoading; private IReadOnlyList? _connectionsDownSeries; private bool _connectionsDownAvailable = true; private string? _connectionsDownError; private IReadOnlyList? _deadLettersSeries; private bool _deadLettersAvailable = true; private string? _deadLettersError; private IReadOnlyList? _scriptErrorsSeries; private bool _scriptErrorsAvailable = true; private string? _scriptErrorsError; private IReadOnlyList? _sfBufferDepthSeries; private bool _sfBufferDepthAvailable = true; private string? _sfBufferDepthError; private IReadOnlyList? _localDbBacklogSeries; private bool _localDbBacklogAvailable = true; private string? _localDbBacklogError; 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 } // First load rides whatever the shared KPI cache already has fresh — a second // operator opening the dashboard inside the window costs no extra query. await RefreshAllAsync(forceRefresh: false); // Site Health Trends (M6) load on their own path — never from the // timer tick below — so a trend-query fault can't disturb the live tile // refresh. Seed the selector from the sites just loaded into _siteStates // and query the default site. await LoadSiteHealthTrendsAsync(); _refreshTimer = new Timer(_ => { _ = _pollGate.RunAsync(() => InvokeAsync(async () => { if (_disposed) return; await RefreshAllAsync(forceRefresh: false); StateHasChanged(); }), error => System.Diagnostics.Trace.TraceWarning("Health refresh failed: {0}", error)); }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); } // Re-query when the operator picks a different site. Best-effort: the load // itself swallows faults per-chart. private async Task OnTrendSiteChangedAsync(ChangeEventArgs e) { var selected = e.Value?.ToString(); if (string.IsNullOrEmpty(selected) || selected == _trendSiteId) { return; } _trendSiteId = selected; await LoadSiteHealthTrendsAsync(refreshSiteKeys: false); } // Re-query when the window toggle changes (24h ↔ 7d). private async Task SetTrendWindowAsync(int windowHours) { if (_trendWindowHours == windowHours) { return; } _trendWindowHours = windowHours; await LoadSiteHealthTrendsAsync(refreshSiteKeys: false); } // Loads the four Site Health trend series for the selected site over the // selected window. Deliberately decoupled from RefreshNow / the 10s timer: // a fault here degrades the affected chart(s) only and never propagates to // the tile-refresh loop. // // refreshSiteKeys re-snapshots the dashboard's site set into the selector // (true on init); the site-change / window-toggle paths pass false so a // mid-interaction site addition/removal can't yank the operator's choice. private async Task LoadSiteHealthTrendsAsync(bool refreshSiteKeys = true) { if (refreshSiteKeys) { // Mirror the dashboard ordering: central cluster pinned first, then // sites alphabetically — the same comparer the detail cards use. _trendSiteKeys = _siteStates.Keys .OrderBy(k => k == CentralHealthReportLoop.CentralSiteId ? 0 : 1) .ThenBy(k => k) .ToList(); // Default to the first site (or keep a still-valid prior selection). if (_trendSiteId == null || !_trendSiteKeys.Contains(_trendSiteId)) { _trendSiteId = _trendSiteKeys.FirstOrDefault(); } } if (string.IsNullOrEmpty(_trendSiteId)) { return; } _trendsLoading = true; try { var toUtc = DateTime.UtcNow; var fromUtc = toUtc - TimeSpan.FromHours(_trendWindowHours); var siteId = _trendSiteId; (_connectionsDownSeries, _connectionsDownAvailable, _connectionsDownError) = await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.ConnectionsDown, siteId, fromUtc, toUtc); (_deadLettersSeries, _deadLettersAvailable, _deadLettersError) = await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.DeadLetters, siteId, fromUtc, toUtc); (_scriptErrorsSeries, _scriptErrorsAvailable, _scriptErrorsError) = await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.ScriptErrors, siteId, fromUtc, toUtc); (_sfBufferDepthSeries, _sfBufferDepthAvailable, _sfBufferDepthError) = await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.SfBufferDepth, siteId, fromUtc, toUtc); (_localDbBacklogSeries, _localDbBacklogAvailable, _localDbBacklogError) = await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.LocalDbOplogBacklog, siteId, fromUtc, toUtc); } finally { _trendsLoading = false; } } // Single best-effort series fetch. Site Health metrics are Site-scoped, so // scope = KpiScopes.Site and scopeKey = the selected site id. On any fault // the chart falls back to the unavailable placeholder — a failure here must // NEVER break the dashboard. private async Task<(IReadOnlyList?, bool, string?)> LoadTrendSeriesAsync( string metric, string siteId, DateTime fromUtc, DateTime toUtc) { try { var series = await KpiHistory.GetSeriesAsync( KpiSources.SiteHealth, metric, KpiScopes.Site, siteId, fromUtc, toUtc); return (series, true, null); } catch { return (null, false, "Trend data unavailable."); } } private string TrendSiteLabel(string siteKey) => siteKey == CentralHealthReportLoop.CentralSiteId ? "Central Cluster" : $"{GetSiteName(siteKey)} ({siteKey})"; // Per-tile budget for one refresh tick. The underlying KPI Asks carry the // 30s CommunicationOptions.QueryTimeout, so without this a single hung // singleton stalled the WHOLE tick (and, with a 10s timer, stacked ticks) // rather than degrading its own tile. Cancelling here abandons only this // circuit's wait — the shared KPI flight continues and its result lands in // the process-level cache for the next tick. private static readonly TimeSpan TileTimeout = TimeSpan.FromSeconds(5); // The operator's explicit "Refresh Now" bypasses the shared cache's freshness // window; the timer tick and the first load ride whatever is already fresh // (which is the whole point — N dashboards share one query per window). private Task RefreshNow() => RefreshAllAsync(forceRefresh: true); // Every tile loads concurrently, each with its own short budget, so one slow // KPI degrades one tile instead of the tick. Every loader swallows its own // faults, so Task.WhenAll here can never observe an exception. private async Task RefreshAllAsync(bool forceRefresh) { _siteStates = HealthAggregator.GetAllSiteStates(); await Task.WhenAll( LoadOutboxKpis(forceRefresh), LoadSiteCallKpis(forceRefresh), LoadSiteCallNodeKpis(forceRefresh), LoadAuditKpis(forceRefresh)); } private async Task LoadOutboxKpis(bool forceRefresh) { try { using var timeout = new CancellationTokenSource(TileTimeout); var response = await KpiSnapshots.GetNotificationKpisAsync(forceRefresh, timeout.Token); if (response.Success) { _outboxKpi = response; _outboxKpiAvailable = true; _outboxKpiError = null; } else { _outboxKpiAvailable = false; _outboxKpiError = response.ErrorMessage ?? "KPI query failed."; } } catch (OperationCanceledException) { _outboxKpiAvailable = false; _outboxKpiError = "KPI query timed out."; } catch (Exception ex) { _outboxKpiAvailable = false; _outboxKpiError = $"KPI query failed: {ex.Message}"; } } // Site Call KPI loader: wraps the service call so a transient fault degrades // the three Site Call tiles to em dashes with an inline error rather than // killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a // response with Success == false (repository fault) and an Ask that threw // (transport fault) both collapse to "unavailable". private async Task LoadSiteCallKpis(bool forceRefresh) { try { using var timeout = new CancellationTokenSource(TileTimeout); var response = await KpiSnapshots.GetSiteCallKpisAsync(forceRefresh, timeout.Token); if (response.Success) { _siteCallKpi = response; _siteCallKpiAvailable = true; _siteCallKpiError = null; } else { _siteCallKpiAvailable = false; _siteCallKpiError = response.ErrorMessage ?? "KPI query failed."; } } catch (OperationCanceledException) { _siteCallKpiAvailable = false; _siteCallKpiError = "KPI query timed out."; } catch (Exception ex) { _siteCallKpiAvailable = false; _siteCallKpiError = $"KPI query failed: {ex.Message}"; } } // Per-node site-call KPI loader (M5.2). Best-effort; a fault silently // suppresses the per-node sub-table rather than degrading the dashboard. private async Task LoadSiteCallNodeKpis(bool forceRefresh) { try { using var timeout = new CancellationTokenSource(TileTimeout); var response = await KpiSnapshots.GetPerNodeSiteCallKpisAsync(forceRefresh, timeout.Token); if (response.Success) { _siteCallNodeKpis = response.Nodes; _siteCallNodeKpiAvailable = true; } else { _siteCallNodeKpiAvailable = false; } } catch { _siteCallNodeKpiAvailable = false; } } // 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(bool forceRefresh) { try { using var timeout = new CancellationTokenSource(TileTimeout); _auditKpi = await KpiSnapshots.GetAuditKpiSnapshotAsync(forceRefresh, timeout.Token); _auditKpiAvailable = true; _auditKpiError = null; } catch (OperationCanceledException) { _auditKpiAvailable = false; _auditKpiError = "KPI query timed out."; } 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" }; private volatile bool _disposed; public void Dispose() { _disposed = true; _refreshTimer?.Dispose(); } }