using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text; using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Domain; using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; using ZB.MOM.WW.OtOpcUa.Host.Historian; using ZB.MOM.WW.OtOpcUa.Host.Metrics; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Host.Status { /// /// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009) /// public class StatusReportService { private readonly HealthCheckService _healthCheck; private readonly int _refreshIntervalSeconds; private readonly DateTime _startTime = DateTime.UtcNow; private string? _applicationUri; private GalaxyRepositoryStats? _galaxyStats; private PerformanceMetrics? _metrics; private HistorianConfiguration? _historianConfig; private IMxAccessClient? _mxAccessClient; private LmxNodeManager? _nodeManager; private RedundancyConfiguration? _redundancyConfig; private OpcUaServerHost? _serverHost; /// /// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh /// interval. /// /// The health-check component used to derive the overall dashboard health status. /// The HTML auto-refresh interval, in seconds, for the dashboard page. public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds) { _healthCheck = healthCheck; _refreshIntervalSeconds = refreshIntervalSeconds; } /// /// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots. /// /// The runtime client whose connection and subscription state should be reported. /// The performance metrics collector whose operation statistics should be reported. /// The Galaxy repository statistics to surface on the dashboard. /// The OPC UA server host whose active session count should be reported. /// /// The node manager whose queue depth and MXAccess event throughput should be surfaced on the /// dashboard. /// public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics, GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost, LmxNodeManager? nodeManager = null, RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null, HistorianConfiguration? historianConfig = null) { _mxAccessClient = mxAccessClient; _metrics = metrics; _galaxyStats = galaxyStats; _serverHost = serverHost; _nodeManager = nodeManager; _redundancyConfig = redundancyConfig; _applicationUri = applicationUri; _historianConfig = historianConfig; } /// /// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers. /// /// The current dashboard status data for the bridge. public StatusData GetStatusData() { var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected; var historianInfo = BuildHistorianStatusInfo(); var alarmInfo = BuildAlarmStatusInfo(); return new StatusData { Connection = new ConnectionInfo { State = connectionState.ToString(), ReconnectCount = _mxAccessClient?.ReconnectCount ?? 0, ActiveSessions = _serverHost?.ActiveSessionCount ?? 0 }, Health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo, BuildRuntimeStatusInfo()), Subscriptions = new SubscriptionInfo { ActiveCount = _mxAccessClient?.ActiveSubscriptionCount ?? 0, ProbeCount = _nodeManager?.ActiveRuntimeProbeCount ?? 0 }, Galaxy = new GalaxyInfo { GalaxyName = _galaxyStats?.GalaxyName ?? "", DbConnected = _galaxyStats?.DbConnected ?? false, LastDeployTime = _galaxyStats?.LastDeployTime, ObjectCount = _galaxyStats?.ObjectCount ?? 0, AttributeCount = _galaxyStats?.AttributeCount ?? 0, LastRebuildTime = _galaxyStats?.LastRebuildTime }, DataChange = new DataChangeInfo { EventsPerSecond = _nodeManager?.MxChangeEventsPerSecond ?? 0, AvgBatchSize = _nodeManager?.AverageDispatchBatchSize ?? 0, PendingItems = _nodeManager?.PendingDataChangeCount ?? 0, TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0 }, Operations = _metrics?.GetStatistics() ?? new Dictionary(), Historian = historianInfo, Alarms = alarmInfo, Redundancy = BuildRedundancyInfo(), Endpoints = BuildEndpointsInfo(), RuntimeStatus = BuildRuntimeStatusInfo(), Footer = new FooterInfo { Timestamp = DateTime.UtcNow, Version = typeof(StatusReportService).Assembly.GetName().Version?.ToString() ?? "1.0.0" } }; } private HistorianStatusInfo BuildHistorianStatusInfo() { var outcome = HistorianPluginLoader.LastOutcome; var health = _nodeManager?.HistorianHealth; return new HistorianStatusInfo { Enabled = _historianConfig?.Enabled ?? false, PluginStatus = outcome.Status.ToString(), PluginError = outcome.Error, PluginPath = outcome.PluginPath, ServerName = _historianConfig?.ServerName ?? "", Port = _historianConfig?.Port ?? 0, QueryTotal = health?.TotalQueries ?? 0, QuerySuccesses = health?.TotalSuccesses ?? 0, QueryFailures = health?.TotalFailures ?? 0, ConsecutiveFailures = health?.ConsecutiveFailures ?? 0, LastSuccessTime = health?.LastSuccessTime, LastFailureTime = health?.LastFailureTime, LastQueryError = health?.LastError, ProcessConnectionOpen = health?.ProcessConnectionOpen ?? false, EventConnectionOpen = health?.EventConnectionOpen ?? false, NodeCount = health?.NodeCount ?? 0, HealthyNodeCount = health?.HealthyNodeCount ?? 0, ActiveProcessNode = health?.ActiveProcessNode, ActiveEventNode = health?.ActiveEventNode, Nodes = health?.Nodes ?? new List() }; } private AlarmStatusInfo BuildAlarmStatusInfo() { return new AlarmStatusInfo { TrackingEnabled = _nodeManager?.AlarmTrackingEnabled ?? false, ConditionCount = _nodeManager?.AlarmConditionCount ?? 0, ActiveAlarmCount = _nodeManager?.ActiveAlarmCount ?? 0, TransitionCount = _nodeManager?.AlarmTransitionCount ?? 0, AckEventCount = _nodeManager?.AlarmAckEventCount ?? 0, AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0, FilterEnabled = _nodeManager?.AlarmFilterEnabled ?? false, FilterPatternCount = _nodeManager?.AlarmFilterPatternCount ?? 0, FilterIncludedObjectCount = _nodeManager?.AlarmFilterIncludedObjectCount ?? 0, FilterPatterns = _nodeManager?.AlarmFilterPatterns?.ToList() ?? new List() }; } private EndpointsInfo BuildEndpointsInfo() { var info = new EndpointsInfo(); if (_serverHost == null) return info; info.BaseAddresses = _serverHost.BaseAddresses.ToList(); info.UserTokenPolicies = _serverHost.UserTokenPolicies.Distinct().ToList(); foreach (var policy in _serverHost.SecurityPolicies) { var uri = policy.SecurityPolicyUri ?? ""; var hashIdx = uri.LastIndexOf('#'); var name = hashIdx >= 0 && hashIdx < uri.Length - 1 ? uri.Substring(hashIdx + 1) : uri; info.SecurityProfiles.Add(new SecurityProfileInfo { PolicyUri = uri, PolicyName = name, SecurityMode = policy.SecurityMode.ToString() }); } return info; } private RuntimeStatusInfo BuildRuntimeStatusInfo() { var hosts = _nodeManager?.RuntimeStatuses?.ToList() ?? new List(); var info = new RuntimeStatusInfo { Total = hosts.Count, Hosts = hosts }; foreach (var host in hosts) { switch (host.State) { case GalaxyRuntimeState.Running: info.RunningCount++; break; case GalaxyRuntimeState.Stopped: info.StoppedCount++; break; default: info.UnknownCount++; break; } } return info; } private RedundancyInfo? BuildRedundancyInfo() { if (_redundancyConfig == null || !_redundancyConfig.Enabled) return null; var mxConnected = (_mxAccessClient?.State ?? ConnectionState.Disconnected) == ConnectionState.Connected; var dbConnected = _galaxyStats?.DbConnected ?? false; var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase); var baseLevel = isPrimary ? _redundancyConfig.ServiceLevelBase : Math.Max(0, _redundancyConfig.ServiceLevelBase - 50); var calculator = new ServiceLevelCalculator(); return new RedundancyInfo { Enabled = true, Mode = _redundancyConfig.Mode, Role = _redundancyConfig.Role, ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected), ApplicationUri = _applicationUri ?? "", ServerUris = new List(_redundancyConfig.ServerUris) }; } /// /// Generates the operator-facing HTML dashboard for the current bridge status. /// /// An HTML document containing the latest dashboard snapshot. public string GenerateHtml() { var data = GetStatusData(); var sb = new StringBuilder(); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($""); sb.AppendLine("LmxOpcUa Status"); sb.AppendLine(""); sb.AppendLine( $"

LmxOpcUa Status Dashboardv{WebUtility.HtmlEncode(data.Footer.Version)}

"); // Connection panel var connColor = data.Connection.State == "Connected" ? "green" : data.Connection.State == "Connecting" ? "yellow" : "red"; sb.AppendLine($"

Connection

"); sb.AppendLine( $"

State: {data.Connection.State} | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}

"); sb.AppendLine("
"); // Health panel sb.AppendLine($"

Health

"); sb.AppendLine($"

Status: {data.Health.Status} — {data.Health.Message}

"); sb.AppendLine("
"); // Endpoints panel (exposed URLs + security profiles) var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray"; sb.AppendLine($"

Endpoints

"); if (data.Endpoints.BaseAddresses.Count == 0) { sb.AppendLine("

No endpoints — OPC UA server not started.

"); } else { sb.AppendLine("

Base Addresses:

    "); foreach (var addr in data.Endpoints.BaseAddresses) sb.AppendLine($"
  • {WebUtility.HtmlEncode(addr)}
  • "); sb.AppendLine("
"); sb.AppendLine("

Security Profiles:

"); sb.AppendLine(""); foreach (var profile in data.Endpoints.SecurityProfiles) { sb.AppendLine( $"" + $"" + $""); } sb.AppendLine("
ModePolicyPolicy URI
{WebUtility.HtmlEncode(profile.SecurityMode)}{WebUtility.HtmlEncode(profile.PolicyName)}{WebUtility.HtmlEncode(profile.PolicyUri)}
"); if (data.Endpoints.UserTokenPolicies.Count > 0) sb.AppendLine( $"

User Token Policies: {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}

"); } sb.AppendLine("
"); // Redundancy panel (only when enabled) if (data.Redundancy != null) { var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow"; sb.AppendLine($"

Redundancy

"); sb.AppendLine( $"

Mode: {data.Redundancy.Mode} | Role: {data.Redundancy.Role} | Service Level: {data.Redundancy.ServiceLevel}

"); sb.AppendLine($"

Application URI: {data.Redundancy.ApplicationUri}

"); sb.AppendLine($"

Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}

"); sb.AppendLine("
"); } // Subscriptions panel sb.AppendLine("

Subscriptions

"); sb.AppendLine($"

Active: {data.Subscriptions.ActiveCount}

"); if (data.Subscriptions.ProbeCount > 0) sb.AppendLine( $"

Probes: {data.Subscriptions.ProbeCount} (bridge-owned runtime status)

"); sb.AppendLine("
"); // Data Change Dispatch panel sb.AppendLine("

Data Change Dispatch

"); sb.AppendLine( $"

Events/sec: {data.DataChange.EventsPerSecond:F1} | Avg Batch Size: {data.DataChange.AvgBatchSize:F1} | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}

"); sb.AppendLine("
"); // Galaxy Info panel sb.AppendLine("

Galaxy Info

"); sb.AppendLine( $"

Galaxy: {data.Galaxy.GalaxyName} | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}

"); sb.AppendLine( $"

Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}

"); sb.AppendLine($"

Last Rebuild: {data.Galaxy.LastRebuildTime:O}

"); sb.AppendLine("
"); // Galaxy Runtime panel — per-host Platform + AppEngine state if (data.RuntimeStatus.Total > 0) { var rtColor = data.RuntimeStatus.StoppedCount > 0 ? "red" : data.RuntimeStatus.UnknownCount > 0 ? "yellow" : "green"; sb.AppendLine($"

Galaxy Runtime

"); sb.AppendLine( $"

{data.RuntimeStatus.RunningCount} of {data.RuntimeStatus.Total} hosts running" + $" ({data.RuntimeStatus.StoppedCount} stopped, {data.RuntimeStatus.UnknownCount} unknown)

"); sb.AppendLine(""); foreach (var host in data.RuntimeStatus.Hosts) { var since = host.LastStateChangeTime?.ToString("O") ?? "-"; var err = WebUtility.HtmlEncode(host.LastError ?? ""); sb.AppendLine( $"" + $"" + $"" + $"" + $""); } sb.AppendLine("
NameKindStateSinceLast Error
{WebUtility.HtmlEncode(host.ObjectName)}{WebUtility.HtmlEncode(host.Kind)}{host.State}{since}{err}
"); sb.AppendLine("
"); } // Historian panel var anyClusterNodeFailed = data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount < data.Historian.NodeCount; var allClusterNodesFailed = data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount == 0; var histColor = !data.Historian.Enabled ? "gray" : data.Historian.PluginStatus != "Loaded" ? "red" : allClusterNodesFailed ? "red" : data.Historian.ConsecutiveFailures >= 5 ? "red" : anyClusterNodeFailed || data.Historian.ConsecutiveFailures > 0 ? "yellow" : "green"; sb.AppendLine($"

Historian

"); sb.AppendLine( $"

Enabled: {data.Historian.Enabled} | Plugin: {data.Historian.PluginStatus} | Port: {data.Historian.Port}

"); if (!string.IsNullOrEmpty(data.Historian.PluginError)) sb.AppendLine($"

Plugin Error: {WebUtility.HtmlEncode(data.Historian.PluginError)}

"); if (data.Historian.PluginStatus == "Loaded") { sb.AppendLine( $"

Queries: {data.Historian.QueryTotal:N0} " + $"(Success: {data.Historian.QuerySuccesses:N0}, Failure: {data.Historian.QueryFailures:N0}) " + $"| Consecutive Failures: {data.Historian.ConsecutiveFailures}

"); var procBadge = data.Historian.ProcessConnectionOpen ? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveProcessNode ?? "?")})" : "closed"; var evtBadge = data.Historian.EventConnectionOpen ? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveEventNode ?? "?")})" : "closed"; sb.AppendLine( $"

Process Conn: {procBadge} | Event Conn: {evtBadge}

"); if (data.Historian.LastSuccessTime.HasValue) sb.AppendLine($"

Last Success: {data.Historian.LastSuccessTime:O}

"); if (data.Historian.LastFailureTime.HasValue) sb.AppendLine($"

Last Failure: {data.Historian.LastFailureTime:O}

"); if (!string.IsNullOrEmpty(data.Historian.LastQueryError)) sb.AppendLine( $"

Last Error: {WebUtility.HtmlEncode(data.Historian.LastQueryError)}

"); // Cluster table: only when a true multi-node cluster is configured. if (data.Historian.NodeCount > 1) { sb.AppendLine( $"

Cluster: {data.Historian.HealthyNodeCount} of {data.Historian.NodeCount} nodes healthy

"); sb.AppendLine( ""); foreach (var node in data.Historian.Nodes) { var state = node.IsHealthy ? "healthy" : "cooldown"; var cooldown = node.CooldownUntil?.ToString("O") ?? "-"; var lastErr = WebUtility.HtmlEncode(node.LastError ?? ""); sb.AppendLine( $"" + $""); } sb.AppendLine("
NodeStateCooldown UntilFailuresLast Error
{WebUtility.HtmlEncode(node.Name)}{state}{cooldown}{node.FailureCount}{lastErr}
"); } else if (data.Historian.NodeCount == 1) { sb.AppendLine($"

Node: {WebUtility.HtmlEncode(data.Historian.Nodes[0].Name)}

"); } } sb.AppendLine("
"); // Alarms panel var alarmPanelColor = !data.Alarms.TrackingEnabled ? "gray" : data.Alarms.AckWriteFailures > 0 ? "yellow" : "green"; sb.AppendLine($"

Alarms

"); sb.AppendLine( $"

Tracking: {data.Alarms.TrackingEnabled} | Conditions: {data.Alarms.ConditionCount} | Active: {data.Alarms.ActiveAlarmCount}

"); sb.AppendLine( $"

Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}

"); if (data.Alarms.FilterEnabled) { sb.AppendLine( $"

Filter: {data.Alarms.FilterPatternCount} pattern(s), {data.Alarms.FilterIncludedObjectCount} object(s) included

"); if (data.Alarms.FilterPatterns.Count > 0) { sb.AppendLine("
    "); foreach (var pattern in data.Alarms.FilterPatterns) sb.AppendLine($"
  • {WebUtility.HtmlEncode(pattern)}
  • "); sb.AppendLine("
"); } } else { sb.AppendLine("

Filter: disabled (all alarm-bearing objects tracked)

"); } sb.AppendLine("
"); // Operations table sb.AppendLine("

Operations

"); sb.AppendLine( ""); foreach (var kvp in data.Operations) { var s = kvp.Value; sb.AppendLine($"" + $""); } sb.AppendLine("
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)P95 (ms)
{kvp.Key}{s.TotalCount}{s.SuccessRate:P1}{s.AverageMilliseconds:F1}{s.MinMilliseconds:F1}{s.MaxMilliseconds:F1}{s.Percentile95Milliseconds:F1}
"); sb.AppendLine(""); return sb.ToString(); } /// /// Generates an indented JSON status payload for API consumers. /// /// A JSON representation of the current dashboard snapshot. public string GenerateJson() { var data = GetStatusData(); return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); } /// /// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint. /// /// when the bridge meets the health policy; otherwise, . public bool IsHealthy() { var state = _mxAccessClient?.State ?? ConnectionState.Disconnected; return _healthCheck.IsHealthy(state, _metrics); } /// /// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state. /// public HealthEndpointData GetHealthData() { var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected; var mxConnected = connectionState == ConnectionState.Connected; var dbConnected = _galaxyStats?.DbConnected ?? false; var historianInfo = BuildHistorianStatusInfo(); var alarmInfo = BuildAlarmStatusInfo(); var health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo); var uptime = DateTime.UtcNow - _startTime; var data = new HealthEndpointData { Status = health.Status, RedundancyEnabled = _redundancyConfig?.Enabled ?? false, Components = new ComponentHealth { MxAccess = connectionState.ToString(), Database = dbConnected ? "Connected" : "Disconnected", OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped", Historian = historianInfo.PluginStatus, Alarms = alarmInfo.TrackingEnabled ? "Enabled" : "Disabled" }, Uptime = FormatUptime(uptime), Timestamp = DateTime.UtcNow }; if (_redundancyConfig != null && _redundancyConfig.Enabled) { var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase); var baseLevel = isPrimary ? _redundancyConfig.ServiceLevelBase : Math.Max(0, _redundancyConfig.ServiceLevelBase - 50); var calculator = new ServiceLevelCalculator(); data.ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected); data.RedundancyRole = _redundancyConfig.Role; data.RedundancyMode = _redundancyConfig.Mode; } else { // Non-redundant: 255 when healthy, 0 when both down data.ServiceLevel = mxConnected ? (byte)255 : (byte)0; } return data; } /// /// Generates the JSON payload for the /api/health endpoint. /// public string GenerateHealthJson() { var data = GetHealthData(); return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); } /// /// Generates a focused health status HTML page for operators and monitoring dashboards. /// public string GenerateHealthHtml() { var data = GetHealthData(); var sb = new StringBuilder(); var statusColor = data.Status == "Healthy" ? "#00cc66" : data.Status == "Degraded" ? "#cccc33" : "#cc3333"; var mxColor = data.Components.MxAccess == "Connected" ? "#00cc66" : "#cc3333"; var dbColor = data.Components.Database == "Connected" ? "#00cc66" : "#cc3333"; var uaColor = data.Components.OpcUaServer == "Running" ? "#00cc66" : "#cc3333"; sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($""); sb.AppendLine("LmxOpcUa Health"); sb.AppendLine(""); // Status badge sb.AppendLine("
"); sb.AppendLine( $"
{data.Status.ToUpperInvariant()}
"); sb.AppendLine("
"); // Service Level sb.AppendLine($"
"); sb.AppendLine("SERVICE LEVEL"); sb.AppendLine($"{data.ServiceLevel}"); sb.AppendLine("
"); // Redundancy info if (data.RedundancyEnabled) sb.AppendLine( $"
Role: {data.RedundancyRole} | Mode: {data.RedundancyMode}
"); var historianColor = data.Components.Historian == "Loaded" ? "#00cc66" : data.Components.Historian == "Disabled" ? "#666" : "#cc3333"; var alarmColor = data.Components.Alarms == "Enabled" ? "#00cc66" : "#666"; // Component health cards sb.AppendLine("
"); sb.AppendLine( $"
MXAccess
{data.Components.MxAccess}
"); sb.AppendLine( $"
Galaxy Database
{data.Components.Database}
"); sb.AppendLine( $"
OPC UA Server
{data.Components.OpcUaServer}
"); sb.AppendLine( $"
Historian
{data.Components.Historian}
"); sb.AppendLine( $"
Alarm Tracking
{data.Components.Alarms}
"); sb.AppendLine("
"); // Footer sb.AppendLine($"
Uptime: {data.Uptime} | {data.Timestamp:O}
"); sb.AppendLine(""); return sb.ToString(); } private static string FormatUptime(TimeSpan ts) { if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m"; if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h {ts.Minutes}m"; return $"{(int)ts.TotalMinutes}m {ts.Seconds}s"; } } }