Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
644 lines
33 KiB
C#
644 lines
33 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009)
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh
|
|
/// interval.
|
|
/// </summary>
|
|
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
|
|
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
|
|
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
|
|
{
|
|
_healthCheck = healthCheck;
|
|
_refreshIntervalSeconds = refreshIntervalSeconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots.
|
|
/// </summary>
|
|
/// <param name="mxAccessClient">The runtime client whose connection and subscription state should be reported.</param>
|
|
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
|
|
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
|
|
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
|
|
/// <param name="nodeManager">
|
|
/// The node manager whose queue depth and MXAccess event throughput should be surfaced on the
|
|
/// dashboard.
|
|
/// </param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers.
|
|
/// </summary>
|
|
/// <returns>The current dashboard status data for the bridge.</returns>
|
|
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<string, MetricsStatistics>(),
|
|
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<Historian.HistorianClusterNodeState>()
|
|
};
|
|
}
|
|
|
|
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<string>()
|
|
};
|
|
}
|
|
|
|
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<GalaxyRuntimeStatus>();
|
|
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<string>(_redundancyConfig.ServerUris)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates the operator-facing HTML dashboard for the current bridge status.
|
|
/// </summary>
|
|
/// <returns>An HTML document containing the latest dashboard snapshot.</returns>
|
|
public string GenerateHtml()
|
|
{
|
|
var data = GetStatusData();
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine("<!DOCTYPE html><html><head>");
|
|
sb.AppendLine("<meta charset='utf-8'>");
|
|
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
|
sb.AppendLine("<title>LmxOpcUa Status</title>");
|
|
sb.AppendLine("<style>");
|
|
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }");
|
|
sb.AppendLine(".panel { border: 2px solid #444; border-radius: 8px; padding: 15px; margin: 10px 0; }");
|
|
sb.AppendLine(
|
|
".green { border-color: #00cc66; } .red { border-color: #cc3333; } .yellow { border-color: #cccc33; } .gray { border-color: #666; }");
|
|
sb.AppendLine(
|
|
"table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; }");
|
|
sb.AppendLine("h2 { margin: 0 0 10px 0; } h1 { color: #66ccff; }");
|
|
sb.AppendLine("h1 .version { color: #888; font-size: 0.5em; font-weight: normal; margin-left: 12px; }");
|
|
sb.AppendLine("</style></head><body>");
|
|
sb.AppendLine(
|
|
$"<h1>LmxOpcUa Status Dashboard<span class='version'>v{WebUtility.HtmlEncode(data.Footer.Version)}</span></h1>");
|
|
|
|
// Connection panel
|
|
var connColor = data.Connection.State == "Connected" ? "green" :
|
|
data.Connection.State == "Connecting" ? "yellow" : "red";
|
|
sb.AppendLine($"<div class='panel {connColor}'><h2>Connection</h2>");
|
|
sb.AppendLine(
|
|
$"<p>State: <b>{data.Connection.State}</b> | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}</p>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Health panel
|
|
sb.AppendLine($"<div class='panel {data.Health.Color}'><h2>Health</h2>");
|
|
sb.AppendLine($"<p>Status: <b>{data.Health.Status}</b> — {data.Health.Message}</p>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Endpoints panel (exposed URLs + security profiles)
|
|
var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray";
|
|
sb.AppendLine($"<div class='panel {endpointsColor}'><h2>Endpoints</h2>");
|
|
if (data.Endpoints.BaseAddresses.Count == 0)
|
|
{
|
|
sb.AppendLine("<p>No endpoints — OPC UA server not started.</p>");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine("<p><b>Base Addresses:</b></p><ul>");
|
|
foreach (var addr in data.Endpoints.BaseAddresses)
|
|
sb.AppendLine($"<li>{WebUtility.HtmlEncode(addr)}</li>");
|
|
sb.AppendLine("</ul>");
|
|
|
|
sb.AppendLine("<p><b>Security Profiles:</b></p>");
|
|
sb.AppendLine("<table><tr><th>Mode</th><th>Policy</th><th>Policy URI</th></tr>");
|
|
foreach (var profile in data.Endpoints.SecurityProfiles)
|
|
{
|
|
sb.AppendLine(
|
|
$"<tr><td>{WebUtility.HtmlEncode(profile.SecurityMode)}</td>" +
|
|
$"<td>{WebUtility.HtmlEncode(profile.PolicyName)}</td>" +
|
|
$"<td>{WebUtility.HtmlEncode(profile.PolicyUri)}</td></tr>");
|
|
}
|
|
sb.AppendLine("</table>");
|
|
|
|
if (data.Endpoints.UserTokenPolicies.Count > 0)
|
|
sb.AppendLine(
|
|
$"<p><b>User Token Policies:</b> {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}</p>");
|
|
}
|
|
sb.AppendLine("</div>");
|
|
|
|
// Redundancy panel (only when enabled)
|
|
if (data.Redundancy != null)
|
|
{
|
|
var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow";
|
|
sb.AppendLine($"<div class='panel {roleColor}'><h2>Redundancy</h2>");
|
|
sb.AppendLine(
|
|
$"<p>Mode: <b>{data.Redundancy.Mode}</b> | Role: <b>{data.Redundancy.Role}</b> | Service Level: <b>{data.Redundancy.ServiceLevel}</b></p>");
|
|
sb.AppendLine($"<p>Application URI: {data.Redundancy.ApplicationUri}</p>");
|
|
sb.AppendLine($"<p>Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}</p>");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
// Subscriptions panel
|
|
sb.AppendLine("<div class='panel gray'><h2>Subscriptions</h2>");
|
|
sb.AppendLine($"<p>Active: <b>{data.Subscriptions.ActiveCount}</b></p>");
|
|
if (data.Subscriptions.ProbeCount > 0)
|
|
sb.AppendLine(
|
|
$"<p>Probes: {data.Subscriptions.ProbeCount} (bridge-owned runtime status)</p>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Data Change Dispatch panel
|
|
sb.AppendLine("<div class='panel gray'><h2>Data Change Dispatch</h2>");
|
|
sb.AppendLine(
|
|
$"<p>Events/sec: <b>{data.DataChange.EventsPerSecond:F1}</b> | Avg Batch Size: <b>{data.DataChange.AvgBatchSize:F1}</b> | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}</p>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Galaxy Info panel
|
|
sb.AppendLine("<div class='panel gray'><h2>Galaxy Info</h2>");
|
|
sb.AppendLine(
|
|
$"<p>Galaxy: <b>{data.Galaxy.GalaxyName}</b> | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}</p>");
|
|
sb.AppendLine(
|
|
$"<p>Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}</p>");
|
|
sb.AppendLine($"<p>Last Rebuild: {data.Galaxy.LastRebuildTime:O}</p>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// 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($"<div class='panel {rtColor}'><h2>Galaxy Runtime</h2>");
|
|
sb.AppendLine(
|
|
$"<p>{data.RuntimeStatus.RunningCount} of {data.RuntimeStatus.Total} hosts running" +
|
|
$" ({data.RuntimeStatus.StoppedCount} stopped, {data.RuntimeStatus.UnknownCount} unknown)</p>");
|
|
sb.AppendLine("<table><tr><th>Name</th><th>Kind</th><th>State</th><th>Since</th><th>Last Error</th></tr>");
|
|
foreach (var host in data.RuntimeStatus.Hosts)
|
|
{
|
|
var since = host.LastStateChangeTime?.ToString("O") ?? "-";
|
|
var err = WebUtility.HtmlEncode(host.LastError ?? "");
|
|
sb.AppendLine(
|
|
$"<tr><td>{WebUtility.HtmlEncode(host.ObjectName)}</td>" +
|
|
$"<td>{WebUtility.HtmlEncode(host.Kind)}</td>" +
|
|
$"<td>{host.State}</td>" +
|
|
$"<td>{since}</td>" +
|
|
$"<td><code>{err}</code></td></tr>");
|
|
}
|
|
sb.AppendLine("</table>");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
// 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($"<div class='panel {histColor}'><h2>Historian</h2>");
|
|
sb.AppendLine(
|
|
$"<p>Enabled: <b>{data.Historian.Enabled}</b> | Plugin: <b>{data.Historian.PluginStatus}</b> | Port: {data.Historian.Port}</p>");
|
|
if (!string.IsNullOrEmpty(data.Historian.PluginError))
|
|
sb.AppendLine($"<p>Plugin Error: {WebUtility.HtmlEncode(data.Historian.PluginError)}</p>");
|
|
if (data.Historian.PluginStatus == "Loaded")
|
|
{
|
|
sb.AppendLine(
|
|
$"<p>Queries: <b>{data.Historian.QueryTotal:N0}</b> " +
|
|
$"(Success: {data.Historian.QuerySuccesses:N0}, Failure: {data.Historian.QueryFailures:N0}) " +
|
|
$"| Consecutive Failures: <b>{data.Historian.ConsecutiveFailures}</b></p>");
|
|
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(
|
|
$"<p>Process Conn: <b>{procBadge}</b> | Event Conn: <b>{evtBadge}</b></p>");
|
|
if (data.Historian.LastSuccessTime.HasValue)
|
|
sb.AppendLine($"<p>Last Success: {data.Historian.LastSuccessTime:O}</p>");
|
|
if (data.Historian.LastFailureTime.HasValue)
|
|
sb.AppendLine($"<p>Last Failure: {data.Historian.LastFailureTime:O}</p>");
|
|
if (!string.IsNullOrEmpty(data.Historian.LastQueryError))
|
|
sb.AppendLine(
|
|
$"<p>Last Error: <code>{WebUtility.HtmlEncode(data.Historian.LastQueryError)}</code></p>");
|
|
|
|
// Cluster table: only when a true multi-node cluster is configured.
|
|
if (data.Historian.NodeCount > 1)
|
|
{
|
|
sb.AppendLine(
|
|
$"<p><b>Cluster:</b> {data.Historian.HealthyNodeCount} of {data.Historian.NodeCount} nodes healthy</p>");
|
|
sb.AppendLine(
|
|
"<table><tr><th>Node</th><th>State</th><th>Cooldown Until</th><th>Failures</th><th>Last Error</th></tr>");
|
|
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(
|
|
$"<tr><td>{WebUtility.HtmlEncode(node.Name)}</td><td>{state}</td>" +
|
|
$"<td>{cooldown}</td><td>{node.FailureCount}</td><td><code>{lastErr}</code></td></tr>");
|
|
}
|
|
sb.AppendLine("</table>");
|
|
}
|
|
else if (data.Historian.NodeCount == 1)
|
|
{
|
|
sb.AppendLine($"<p>Node: {WebUtility.HtmlEncode(data.Historian.Nodes[0].Name)}</p>");
|
|
}
|
|
}
|
|
sb.AppendLine("</div>");
|
|
|
|
// Alarms panel
|
|
var alarmPanelColor = !data.Alarms.TrackingEnabled ? "gray"
|
|
: data.Alarms.AckWriteFailures > 0 ? "yellow" : "green";
|
|
sb.AppendLine($"<div class='panel {alarmPanelColor}'><h2>Alarms</h2>");
|
|
sb.AppendLine(
|
|
$"<p>Tracking: <b>{data.Alarms.TrackingEnabled}</b> | Conditions: {data.Alarms.ConditionCount} | Active: <b>{data.Alarms.ActiveAlarmCount}</b></p>");
|
|
sb.AppendLine(
|
|
$"<p>Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}</p>");
|
|
if (data.Alarms.FilterEnabled)
|
|
{
|
|
sb.AppendLine(
|
|
$"<p>Filter: <b>{data.Alarms.FilterPatternCount}</b> pattern(s), <b>{data.Alarms.FilterIncludedObjectCount}</b> object(s) included</p>");
|
|
if (data.Alarms.FilterPatterns.Count > 0)
|
|
{
|
|
sb.AppendLine("<ul>");
|
|
foreach (var pattern in data.Alarms.FilterPatterns)
|
|
sb.AppendLine($"<li><code>{WebUtility.HtmlEncode(pattern)}</code></li>");
|
|
sb.AppendLine("</ul>");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine("<p>Filter: <b>disabled</b> (all alarm-bearing objects tracked)</p>");
|
|
}
|
|
sb.AppendLine("</div>");
|
|
|
|
// Operations table
|
|
sb.AppendLine("<div class='panel gray'><h2>Operations</h2>");
|
|
sb.AppendLine(
|
|
"<table><tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
|
|
foreach (var kvp in data.Operations)
|
|
{
|
|
var s = kvp.Value;
|
|
sb.AppendLine($"<tr><td>{kvp.Key}</td><td>{s.TotalCount}</td><td>{s.SuccessRate:P1}</td>" +
|
|
$"<td>{s.AverageMilliseconds:F1}</td><td>{s.MinMilliseconds:F1}</td><td>{s.MaxMilliseconds:F1}</td><td>{s.Percentile95Milliseconds:F1}</td></tr>");
|
|
}
|
|
|
|
sb.AppendLine("</table></div>");
|
|
|
|
sb.AppendLine("</body></html>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates an indented JSON status payload for API consumers.
|
|
/// </summary>
|
|
/// <returns>A JSON representation of the current dashboard snapshot.</returns>
|
|
public string GenerateJson()
|
|
{
|
|
var data = GetStatusData();
|
|
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint.
|
|
/// </summary>
|
|
/// <returns><see langword="true" /> when the bridge meets the health policy; otherwise, <see langword="false" />.</returns>
|
|
public bool IsHealthy()
|
|
{
|
|
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
|
return _healthCheck.IsHealthy(state, _metrics);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates the JSON payload for the /api/health endpoint.
|
|
/// </summary>
|
|
public string GenerateHealthJson()
|
|
{
|
|
var data = GetHealthData();
|
|
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a focused health status HTML page for operators and monitoring dashboards.
|
|
/// </summary>
|
|
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("<!DOCTYPE html><html><head>");
|
|
sb.AppendLine("<meta charset='utf-8'>");
|
|
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
|
sb.AppendLine("<title>LmxOpcUa Health</title>");
|
|
sb.AppendLine("<style>");
|
|
sb.AppendLine(
|
|
"body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }");
|
|
sb.AppendLine(".header { text-align: center; padding: 30px 0; }");
|
|
sb.AppendLine(
|
|
".status-badge { display: inline-block; font-size: 2em; font-weight: bold; padding: 15px 40px; border-radius: 12px; letter-spacing: 2px; }");
|
|
sb.AppendLine(".service-level { text-align: center; font-size: 4em; font-weight: bold; margin: 20px 0; }");
|
|
sb.AppendLine(".service-level .label { font-size: 0.3em; color: #999; display: block; }");
|
|
sb.AppendLine(
|
|
".components { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin: 30px auto; max-width: 800px; }");
|
|
sb.AppendLine(
|
|
".component { border: 2px solid #444; border-radius: 8px; padding: 20px; min-width: 200px; text-align: center; }");
|
|
sb.AppendLine(".component .name { font-size: 0.9em; color: #999; margin-bottom: 8px; }");
|
|
sb.AppendLine(".component .value { font-size: 1.3em; font-weight: bold; }");
|
|
sb.AppendLine(".meta { text-align: center; margin-top: 30px; color: #666; font-size: 0.85em; }");
|
|
sb.AppendLine(".redundancy { text-align: center; margin: 10px 0; color: #999; }");
|
|
sb.AppendLine(".redundancy b { color: #66ccff; }");
|
|
sb.AppendLine("</style></head><body>");
|
|
|
|
// Status badge
|
|
sb.AppendLine("<div class='header'>");
|
|
sb.AppendLine(
|
|
$"<div class='status-badge' style='background: {statusColor}; color: #000;'>{data.Status.ToUpperInvariant()}</div>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Service Level
|
|
sb.AppendLine($"<div class='service-level' style='color: {statusColor};'>");
|
|
sb.AppendLine("<span class='label'>SERVICE LEVEL</span>");
|
|
sb.AppendLine($"{data.ServiceLevel}");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Redundancy info
|
|
if (data.RedundancyEnabled)
|
|
sb.AppendLine(
|
|
$"<div class='redundancy'>Role: <b>{data.RedundancyRole}</b> | Mode: <b>{data.RedundancyMode}</b></div>");
|
|
|
|
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("<div class='components'>");
|
|
sb.AppendLine(
|
|
$"<div class='component' style='border-color: {mxColor};'><div class='name'>MXAccess</div><div class='value' style='color: {mxColor};'>{data.Components.MxAccess}</div></div>");
|
|
sb.AppendLine(
|
|
$"<div class='component' style='border-color: {dbColor};'><div class='name'>Galaxy Database</div><div class='value' style='color: {dbColor};'>{data.Components.Database}</div></div>");
|
|
sb.AppendLine(
|
|
$"<div class='component' style='border-color: {uaColor};'><div class='name'>OPC UA Server</div><div class='value' style='color: {uaColor};'>{data.Components.OpcUaServer}</div></div>");
|
|
sb.AppendLine(
|
|
$"<div class='component' style='border-color: {historianColor};'><div class='name'>Historian</div><div class='value' style='color: {historianColor};'>{data.Components.Historian}</div></div>");
|
|
sb.AppendLine(
|
|
$"<div class='component' style='border-color: {alarmColor};'><div class='name'>Alarm Tracking</div><div class='value' style='color: {alarmColor};'>{data.Components.Alarms}</div></div>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Footer
|
|
sb.AppendLine($"<div class='meta'>Uptime: {data.Uptime} | {data.Timestamp:O}</div>");
|
|
|
|
sb.AppendLine("</body></html>");
|
|
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";
|
|
}
|
|
}
|
|
} |