73fe618953
ReadAsync internally subscribes/unsubscribes the same ScanTime tag used by the persistent probe, which was tearing down the probe subscription and triggering false reconnects every ~5s. Guard UnsubscribeInternal and stored subscription state so the probe tag is never removed by other callers. Also removes DetailedHealthCheckService (redundant with the persistent probe), adds per-instance config files (appsettings.v2.json, appsettings.v2b.json) loaded via LMXPROXY_INSTANCE env var so deploys no longer overwrite port settings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
284 lines
13 KiB
C#
284 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Serialization;
|
|
using Serilog;
|
|
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
|
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
|
|
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
|
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
|
|
|
namespace ZB.MOM.WW.LmxProxy.Host.Status
|
|
{
|
|
/// <summary>
|
|
/// Aggregates health, metrics, and subscription data into status reports.
|
|
/// </summary>
|
|
public class StatusReportService
|
|
{
|
|
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
|
|
|
|
private readonly IScadaClient _scadaClient;
|
|
private readonly SubscriptionManager _subscriptionManager;
|
|
private readonly PerformanceMetrics _performanceMetrics;
|
|
private readonly HealthCheckService _healthCheckService;
|
|
|
|
public StatusReportService(
|
|
IScadaClient scadaClient,
|
|
SubscriptionManager subscriptionManager,
|
|
PerformanceMetrics performanceMetrics,
|
|
HealthCheckService healthCheckService)
|
|
{
|
|
_scadaClient = scadaClient;
|
|
_subscriptionManager = subscriptionManager;
|
|
_performanceMetrics = performanceMetrics;
|
|
_healthCheckService = healthCheckService;
|
|
}
|
|
|
|
public async Task<string> GenerateHtmlReportAsync()
|
|
{
|
|
try
|
|
{
|
|
var statusData = await CollectStatusDataAsync();
|
|
return GenerateHtmlFromStatusData(statusData);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Failed to generate HTML report");
|
|
return GenerateErrorHtml(ex);
|
|
}
|
|
}
|
|
|
|
public async Task<string> GenerateJsonReportAsync()
|
|
{
|
|
var statusData = await CollectStatusDataAsync();
|
|
var settings = new JsonSerializerSettings
|
|
{
|
|
Formatting = Formatting.Indented,
|
|
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
|
};
|
|
return JsonConvert.SerializeObject(statusData, settings);
|
|
}
|
|
|
|
public async Task<bool> IsHealthyAsync()
|
|
{
|
|
var result = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
|
|
return result.Status == HealthStatus.Healthy;
|
|
}
|
|
|
|
private async Task<StatusData> CollectStatusDataAsync()
|
|
{
|
|
var statusData = new StatusData
|
|
{
|
|
Timestamp = DateTime.UtcNow,
|
|
ServiceName = "ZB.MOM.WW.LmxProxy.Host",
|
|
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0"
|
|
};
|
|
|
|
// Connection info
|
|
statusData.Connection = new ConnectionStatus
|
|
{
|
|
IsConnected = _scadaClient.IsConnected,
|
|
State = _scadaClient.ConnectionState.ToString()
|
|
};
|
|
|
|
// Subscription stats
|
|
var subStats = _subscriptionManager.GetStats();
|
|
statusData.Subscriptions = new SubscriptionStatus
|
|
{
|
|
TotalClients = subStats.TotalClients,
|
|
TotalTags = subStats.TotalTags,
|
|
ActiveSubscriptions = subStats.ActiveSubscriptions,
|
|
TotalDelivered = subStats.TotalDelivered,
|
|
TotalDropped = subStats.TotalDropped
|
|
};
|
|
|
|
// Performance stats
|
|
var allStats = _performanceMetrics.GetStatistics();
|
|
long totalOps = 0;
|
|
double totalSuccessRate = 0;
|
|
int opCount = 0;
|
|
|
|
foreach (var kvp in allStats)
|
|
{
|
|
totalOps += kvp.Value.TotalCount;
|
|
totalSuccessRate += kvp.Value.SuccessRate;
|
|
opCount++;
|
|
|
|
statusData.Performance.Operations[kvp.Key] = new OperationStatus
|
|
{
|
|
TotalCount = kvp.Value.TotalCount,
|
|
SuccessRate = kvp.Value.SuccessRate,
|
|
AverageMilliseconds = kvp.Value.AverageMilliseconds,
|
|
MinMilliseconds = kvp.Value.MinMilliseconds,
|
|
MaxMilliseconds = kvp.Value.MaxMilliseconds,
|
|
Percentile95Milliseconds = kvp.Value.Percentile95Milliseconds
|
|
};
|
|
}
|
|
|
|
statusData.Performance.TotalOperations = totalOps;
|
|
statusData.Performance.AverageSuccessRate = opCount > 0
|
|
? totalSuccessRate / opCount
|
|
: 1.0;
|
|
|
|
// Health check
|
|
var healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
|
|
statusData.Health = new HealthInfo
|
|
{
|
|
Status = healthResult.Status.ToString(),
|
|
Description = healthResult.Description ?? ""
|
|
};
|
|
if (healthResult.Data != null)
|
|
{
|
|
foreach (var kvp in healthResult.Data)
|
|
{
|
|
statusData.Health.Data[kvp.Key] = kvp.Value?.ToString() ?? "";
|
|
}
|
|
}
|
|
|
|
return statusData;
|
|
}
|
|
|
|
private static string GenerateHtmlFromStatusData(StatusData statusData)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("<!DOCTYPE html>");
|
|
sb.AppendLine("<html lang=\"en\">");
|
|
sb.AppendLine("<head>");
|
|
sb.AppendLine(" <meta charset=\"utf-8\">");
|
|
sb.AppendLine(" <meta http-equiv=\"refresh\" content=\"30\">");
|
|
sb.AppendLine(" <title>LmxProxy Status</title>");
|
|
sb.AppendLine(" <style>");
|
|
sb.AppendLine(" body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #f5f5f5; }");
|
|
sb.AppendLine(" h1 { color: #333; }");
|
|
sb.AppendLine(" .card { background: white; border-radius: 4px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }");
|
|
sb.AppendLine(" .card-green { border-left: 4px solid #28a745; }");
|
|
sb.AppendLine(" .card-yellow { border-left: 4px solid #ffc107; }");
|
|
sb.AppendLine(" .card-red { border-left: 4px solid #dc3545; }");
|
|
sb.AppendLine(" .grid { display: flex; flex-wrap: wrap; gap: 16px; }");
|
|
sb.AppendLine(" .grid-item { flex: 1; min-width: 300px; }");
|
|
sb.AppendLine(" table { width: 100%; border-collapse: collapse; }");
|
|
sb.AppendLine(" th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }");
|
|
sb.AppendLine(" th { background: #f8f9fa; font-weight: 600; }");
|
|
sb.AppendLine(" .status-healthy { color: #28a745; font-weight: bold; }");
|
|
sb.AppendLine(" .status-degraded { color: #ffc107; font-weight: bold; }");
|
|
sb.AppendLine(" .status-unhealthy { color: #dc3545; font-weight: bold; }");
|
|
sb.AppendLine(" .footer { color: #999; font-size: 0.85em; margin-top: 20px; }");
|
|
sb.AppendLine(" </style>");
|
|
sb.AppendLine("</head>");
|
|
sb.AppendLine("<body>");
|
|
sb.AppendLine(" <h1>LmxProxy Status Dashboard</h1>");
|
|
|
|
// Connection card
|
|
var connClass = statusData.Connection.IsConnected ? "card-green" : "card-red";
|
|
sb.AppendLine($" <div class=\"grid\">");
|
|
sb.AppendLine($" <div class=\"grid-item\"><div class=\"card {connClass}\">");
|
|
sb.AppendLine(" <h3>Connection</h3>");
|
|
sb.AppendLine($" <p><strong>Connected:</strong> {statusData.Connection.IsConnected}</p>");
|
|
sb.AppendLine($" <p><strong>State:</strong> {statusData.Connection.State}</p>");
|
|
if (!string.IsNullOrEmpty(statusData.Connection.NodeName))
|
|
sb.AppendLine($" <p><strong>Node:</strong> {statusData.Connection.NodeName}</p>");
|
|
if (!string.IsNullOrEmpty(statusData.Connection.GalaxyName))
|
|
sb.AppendLine($" <p><strong>Galaxy:</strong> {statusData.Connection.GalaxyName}</p>");
|
|
sb.AppendLine(" </div></div>");
|
|
|
|
// Health card
|
|
var healthClass = GetHealthCardClass(statusData.Health.Status);
|
|
var healthCss = GetHealthStatusCss(statusData.Health.Status);
|
|
sb.AppendLine($" <div class=\"grid-item\"><div class=\"card {healthClass}\">");
|
|
sb.AppendLine(" <h3>Health</h3>");
|
|
sb.AppendLine($" <p class=\"{healthCss}\">{statusData.Health.Status}</p>");
|
|
sb.AppendLine($" <p>{statusData.Health.Description}</p>");
|
|
sb.AppendLine(" </div></div>");
|
|
|
|
// Subscriptions card
|
|
var subCardCss = statusData.Subscriptions.TotalDropped > 0 ? "card-yellow" : "card-green";
|
|
sb.AppendLine($" <div class=\"grid-item\"><div class=\"card {subCardCss}\">");
|
|
sb.AppendLine(" <h3>Subscriptions</h3>");
|
|
sb.AppendLine($" <p><strong>Clients:</strong> {statusData.Subscriptions.TotalClients}</p>");
|
|
sb.AppendLine($" <p><strong>Tags:</strong> {statusData.Subscriptions.TotalTags}</p>");
|
|
sb.AppendLine($" <p><strong>Active:</strong> {statusData.Subscriptions.ActiveSubscriptions}</p>");
|
|
sb.AppendLine($" <p><strong>Delivered:</strong> {statusData.Subscriptions.TotalDelivered:N0}</p>");
|
|
if (statusData.Subscriptions.TotalDropped > 0)
|
|
{
|
|
sb.AppendLine($" <p style=\"color:red\"><strong>Dropped:</strong> {statusData.Subscriptions.TotalDropped:N0}</p>");
|
|
}
|
|
sb.AppendLine(" </div></div>");
|
|
sb.AppendLine(" </div>");
|
|
|
|
// RPC Operations table (always shown)
|
|
sb.AppendLine(" <div class=\"card\">");
|
|
sb.AppendLine(" <h3>RPC Operations</h3>");
|
|
sb.AppendLine(" <table>");
|
|
sb.AppendLine(" <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>");
|
|
|
|
// All known RPC operations — show each even if 0 calls
|
|
var rpcNames = new[] { "Read", "ReadBatch", "Write", "WriteBatch", "Subscribe" };
|
|
foreach (var rpcName in rpcNames)
|
|
{
|
|
var key = rpcName.Substring(0, 1).ToLowerInvariant() + rpcName.Substring(1);
|
|
if (statusData.Performance.Operations.TryGetValue(key, out var op))
|
|
{
|
|
sb.AppendLine($" <tr>" +
|
|
$"<td>{rpcName}</td>" +
|
|
$"<td>{op.TotalCount}</td>" +
|
|
$"<td>{op.SuccessRate:P1}</td>" +
|
|
$"<td>{op.AverageMilliseconds:F1}</td>" +
|
|
$"<td>{op.MinMilliseconds:F1}</td>" +
|
|
$"<td>{op.MaxMilliseconds:F1}</td>" +
|
|
$"<td>{op.Percentile95Milliseconds:F1}</td>" +
|
|
$"</tr>");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($" <tr><td>{rpcName}</td><td>0</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>");
|
|
}
|
|
}
|
|
|
|
sb.AppendLine(" </table>");
|
|
sb.AppendLine(" </div>");
|
|
|
|
sb.AppendLine($" <div class=\"footer\">Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC | Service: {statusData.ServiceName} v{statusData.Version}</div>");
|
|
sb.AppendLine("</body>");
|
|
sb.AppendLine("</html>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string GetHealthCardClass(string status)
|
|
{
|
|
switch (status)
|
|
{
|
|
case "Healthy": return "card-green";
|
|
case "Degraded": return "card-yellow";
|
|
default: return "card-red";
|
|
}
|
|
}
|
|
|
|
private static string GetHealthStatusCss(string status)
|
|
{
|
|
switch (status)
|
|
{
|
|
case "Healthy": return "status-healthy";
|
|
case "Degraded": return "status-degraded";
|
|
default: return "status-unhealthy";
|
|
}
|
|
}
|
|
|
|
private static string GenerateErrorHtml(Exception ex)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("<!DOCTYPE html>");
|
|
sb.AppendLine("<html><head><title>LmxProxy Status - Error</title></head>");
|
|
sb.AppendLine("<body>");
|
|
sb.AppendLine("<h1>Error generating status report</h1>");
|
|
sb.AppendLine($"<p>{ex.Message}</p>");
|
|
sb.AppendLine("</body></html>");
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
}
|