Files
ScadaBridge/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs
T
Joseph Doherty 7f74b660b3 feat(lmxproxy): add delivered/dropped message counts to subscription stats
Subscription metrics (totalDelivered, totalDropped) now visible in
/api/status JSON and HTML dashboard. Card turns yellow if drops > 0.
Aggregated from per-client counters in SubscriptionManager.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:07:58 -04:00

311 lines
14 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 ZB.MOM.WW.LmxProxy.Host.Health;
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;
private readonly DetailedHealthCheckService? _detailedHealthCheckService;
public StatusReportService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics,
HealthCheckService healthCheckService,
DetailedHealthCheckService? detailedHealthCheckService = null)
{
_scadaClient = scadaClient;
_subscriptionManager = subscriptionManager;
_performanceMetrics = performanceMetrics;
_healthCheckService = healthCheckService;
_detailedHealthCheckService = detailedHealthCheckService;
}
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() ?? "";
}
}
// Detailed health check (optional)
if (_detailedHealthCheckService != null)
{
var detailedResult = await _detailedHealthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.DetailedHealth = new HealthInfo
{
Status = detailedResult.Status.ToString(),
Description = detailedResult.Description ?? ""
};
if (detailedResult.Data != null)
{
foreach (var kvp in detailedResult.Data)
{
statusData.DetailedHealth.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>");
// Operations table
if (statusData.Performance.Operations.Count > 0)
{
sb.AppendLine(" <div class=\"card\">");
sb.AppendLine(" <h3>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>");
foreach (var op in statusData.Performance.Operations)
{
sb.AppendLine($" <tr>" +
$"<td>{op.Key}</td>" +
$"<td>{op.Value.TotalCount}</td>" +
$"<td>{op.Value.SuccessRate:P1}</td>" +
$"<td>{op.Value.AverageMilliseconds:F1}</td>" +
$"<td>{op.Value.MinMilliseconds:F1}</td>" +
$"<td>{op.Value.MaxMilliseconds:F1}</td>" +
$"<td>{op.Value.Percentile95Milliseconds:F1}</td>" +
$"</tr>");
}
sb.AppendLine(" </table>");
sb.AppendLine(" </div>");
}
// Detailed health (if available)
if (statusData.DetailedHealth != null)
{
var detailedClass = GetHealthCardClass(statusData.DetailedHealth.Status);
var detailedCss = GetHealthStatusCss(statusData.DetailedHealth.Status);
sb.AppendLine($" <div class=\"card {detailedClass}\">");
sb.AppendLine(" <h3>Detailed Health Check</h3>");
sb.AppendLine($" <p class=\"{detailedCss}\">{statusData.DetailedHealth.Status}</p>");
sb.AppendLine($" <p>{statusData.DetailedHealth.Description}</p>");
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();
}
}
}