feat(lmxproxy): phase 4 — host health monitoring, metrics, status web server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 00:14:40 -04:00
parent 16d1b95e9a
commit 9eb81180c0
12 changed files with 1546 additions and 12 deletions

View File

@@ -3,8 +3,10 @@ using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Core;
using GrpcStatus = Grpc.Core.Status;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Metrics;
using ZB.MOM.WW.LmxProxy.Host.Sessions;
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
@@ -21,15 +23,18 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
private readonly IScadaClient _scadaClient;
private readonly SessionManager _sessionManager;
private readonly SubscriptionManager _subscriptionManager;
private readonly PerformanceMetrics? _performanceMetrics;
public ScadaGrpcService(
IScadaClient scadaClient,
SessionManager sessionManager,
SubscriptionManager subscriptionManager)
SubscriptionManager subscriptionManager,
PerformanceMetrics? performanceMetrics = null)
{
_scadaClient = scadaClient;
_sessionManager = sessionManager;
_subscriptionManager = subscriptionManager;
_performanceMetrics = performanceMetrics;
}
// -- Connection Management ------------------------------------
@@ -121,6 +126,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
};
}
using var timing = _performanceMetrics?.BeginOperation("Read");
try
{
var vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken);
@@ -133,6 +139,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
}
catch (Exception ex)
{
timing?.SetSuccess(false);
Log.Error(ex, "Read failed for tag {Tag}", request.Tag);
return new Scada.ReadResponse
{
@@ -155,6 +162,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
};
}
using var timing = _performanceMetrics?.BeginOperation("ReadBatch");
try
{
var results = await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken);
@@ -182,6 +190,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
}
catch (Exception ex)
{
timing?.SetSuccess(false);
Log.Error(ex, "ReadBatch failed");
return new Scada.ReadBatchResponse
{
@@ -201,6 +210,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
return new Scada.WriteResponse { Success = false, Message = "Invalid session" };
}
using var timing = _performanceMetrics?.BeginOperation("Write");
try
{
var value = TypedValueConverter.FromTypedValue(request.Value);
@@ -209,6 +219,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
}
catch (Exception ex)
{
timing?.SetSuccess(false);
Log.Error(ex, "Write failed for tag {Tag}", request.Tag);
return new Scada.WriteResponse { Success = false, Message = ex.Message };
}
@@ -222,6 +233,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
return new Scada.WriteBatchResponse { Success = false, Message = "Invalid session" };
}
using var timing = _performanceMetrics?.BeginOperation("WriteBatch");
var response = new Scada.WriteBatchResponse { Success = true, Message = "" };
foreach (var item in request.Items)
@@ -245,6 +257,11 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
}
}
if (!response.Success)
{
timing?.SetSuccess(false);
}
return response;
}
@@ -336,7 +353,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
{
if (!_sessionManager.ValidateSession(request.SessionId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session"));
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid session"));
}
var reader = _subscriptionManager.Subscribe(
@@ -360,7 +377,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
catch (Exception ex)
{
Log.Error(ex, "Subscribe stream error for session {SessionId}", request.SessionId);
throw new RpcException(new Status(StatusCode.Internal, ex.Message));
throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message));
}
finally
{

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Health
{
/// <summary>
/// Detailed health check: reads a test tag, checks quality and timestamp staleness.
/// </summary>
public class DetailedHealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<DetailedHealthCheckService>();
private readonly IScadaClient _scadaClient;
private readonly string _testTagAddress;
public DetailedHealthCheckService(
IScadaClient scadaClient,
string testTagAddress = "TestChildObject.TestBool")
{
_scadaClient = scadaClient;
_testTagAddress = testTagAddress;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
if (!_scadaClient.IsConnected)
{
return HealthCheckResult.Unhealthy("SCADA client is not connected");
}
Vtq vtq;
try
{
vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken);
}
catch (Exception ex)
{
Logger.Warning(ex, "Could not read test tag {Tag}", _testTagAddress);
return HealthCheckResult.Degraded(
"Could not read test tag: " + ex.Message,
data: new Dictionary<string, object>
{
{ "test_tag", _testTagAddress },
{ "error", ex.Message }
});
}
var data = new Dictionary<string, object>
{
{ "test_tag", _testTagAddress },
{ "quality", vtq.Quality.ToString() },
{ "timestamp", vtq.Timestamp.ToString("o") }
};
if (!vtq.Quality.IsGood())
{
return HealthCheckResult.Degraded(
"Test tag quality is not Good: " + vtq.Quality,
data: data);
}
if (DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5))
{
return HealthCheckResult.Degraded(
"Test tag data is stale (older than 5 minutes)",
data: data);
}
return HealthCheckResult.Healthy(
"Test tag read successful with good quality",
data: data);
}
catch (Exception ex)
{
Logger.Error(ex, "Detailed health check failed");
return HealthCheckResult.Unhealthy(
"Detailed health check failed: " + ex.Message, ex);
}
}
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Metrics;
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
namespace ZB.MOM.WW.LmxProxy.Host.Health
{
/// <summary>
/// Basic health check: connection state, success rate, client count.
/// </summary>
public class HealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
private readonly PerformanceMetrics _performanceMetrics;
public HealthCheckService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics)
{
_scadaClient = scadaClient;
_subscriptionManager = subscriptionManager;
_performanceMetrics = performanceMetrics;
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var data = new Dictionary<string, object>();
var isConnected = _scadaClient.IsConnected;
data["scada_connected"] = isConnected;
data["scada_connection_state"] = _scadaClient.ConnectionState.ToString();
var subscriptionStats = _subscriptionManager.GetStats();
data["subscription_total_clients"] = subscriptionStats.TotalClients;
data["subscription_total_tags"] = subscriptionStats.TotalTags;
long totalOperations = 0;
double totalSuccessRate = 0;
int operationCount = 0;
foreach (var kvp in _performanceMetrics.GetAllMetrics())
{
var stats = kvp.Value.GetStatistics();
totalOperations += stats.TotalCount;
totalSuccessRate += stats.SuccessRate;
operationCount++;
}
double averageSuccessRate = operationCount > 0
? totalSuccessRate / operationCount
: 1.0;
data["total_operations"] = totalOperations;
data["average_success_rate"] = averageSuccessRate;
if (!isConnected)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
"SCADA client is not connected", data: data));
}
if (averageSuccessRate < 0.5 && totalOperations > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
"Average success rate is below 50%", data: data));
}
if (subscriptionStats.TotalClients > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
"High client count: " + subscriptionStats.TotalClients, data: data));
}
return Task.FromResult(HealthCheckResult.Healthy(
"LmxProxy is healthy", data: data));
}
catch (Exception ex)
{
Logger.Error(ex, "Health check failed");
return Task.FromResult(HealthCheckResult.Unhealthy(
"Health check failed: " + ex.Message, ex));
}
}
}
}

View File

@@ -7,7 +7,10 @@ using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
using ZB.MOM.WW.LmxProxy.Host.MxAccess;
using ZB.MOM.WW.LmxProxy.Host.Security;
using ZB.MOM.WW.LmxProxy.Host.Health;
using ZB.MOM.WW.LmxProxy.Host.Metrics;
using ZB.MOM.WW.LmxProxy.Host.Sessions;
using ZB.MOM.WW.LmxProxy.Host.Status;
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
namespace ZB.MOM.WW.LmxProxy.Host
@@ -25,6 +28,11 @@ namespace ZB.MOM.WW.LmxProxy.Host
private SessionManager? _sessionManager;
private SubscriptionManager? _subscriptionManager;
private ApiKeyService? _apiKeyService;
private PerformanceMetrics? _performanceMetrics;
private HealthCheckService? _healthCheckService;
private DetailedHealthCheckService? _detailedHealthCheckService;
private StatusReportService? _statusReportService;
private StatusWebServer? _statusWebServer;
private Server? _grpcServer;
public LmxProxyService(LmxProxyConfiguration config)
@@ -98,14 +106,33 @@ namespace ZB.MOM.WW.LmxProxy.Host
// 8. Create SessionManager
_sessionManager = new SessionManager(inactivityTimeoutMinutes: 5);
// 9. Create gRPC service
var grpcService = new ScadaGrpcService(
_mxAccessClient, _sessionManager, _subscriptionManager);
// 9. Create performance metrics
_performanceMetrics = new PerformanceMetrics();
// 10. Create and configure interceptor
// 10. Create health check services
_healthCheckService = new HealthCheckService(_mxAccessClient, _subscriptionManager, _performanceMetrics);
_detailedHealthCheckService = new DetailedHealthCheckService(_mxAccessClient);
// 11. Create status report service
_statusReportService = new StatusReportService(
_mxAccessClient, _subscriptionManager, _performanceMetrics,
_healthCheckService, _detailedHealthCheckService);
// 12. Start status web server
_statusWebServer = new StatusWebServer(_config.WebServer, _statusReportService);
if (!_statusWebServer.Start())
{
Log.Warning("Status web server failed to start — continuing without it");
}
// 13. Create gRPC service
var grpcService = new ScadaGrpcService(
_mxAccessClient, _sessionManager, _subscriptionManager, _performanceMetrics);
// 14. Create and configure interceptor
var interceptor = new ApiKeyInterceptor(_apiKeyService);
// 11. Build and start gRPC server
// 15. Build and start gRPC server
_grpcServer = new Server
{
Services =
@@ -144,7 +171,13 @@ namespace ZB.MOM.WW.LmxProxy.Host
// 1. Stop reconnect monitor (5s wait)
_mxAccessClient?.StopMonitorLoop();
// 2. Graceful gRPC shutdown (10s timeout, then kill)
// 2. Stop status web server
_statusWebServer?.Stop();
// 3. Dispose performance metrics
_performanceMetrics?.Dispose();
// 4. Graceful gRPC shutdown (10s timeout, then kill)
if (_grpcServer != null)
{
Log.Information("Shutting down gRPC server...");

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Metrics
{
/// <summary>
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation"/>.
/// </summary>
public interface ITimingScope : IDisposable
{
void SetSuccess(bool success);
}
/// <summary>
/// Statistics snapshot for a single operation type.
/// </summary>
public class MetricsStatistics
{
public long TotalCount { get; set; }
public long SuccessCount { get; set; }
public double SuccessRate { get; set; }
public double AverageMilliseconds { get; set; }
public double MinMilliseconds { get; set; }
public double MaxMilliseconds { get; set; }
public double Percentile95Milliseconds { get; set; }
}
/// <summary>
/// Per-operation timing and success tracking with a rolling buffer for percentile computation.
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new List<double>();
private readonly object _lock = new object();
private long _totalCount;
private long _successCount;
private double _totalMilliseconds;
private double _minMilliseconds = double.MaxValue;
private double _maxMilliseconds;
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
{
_totalCount++;
if (success)
{
_successCount++;
}
var ms = duration.TotalMilliseconds;
_durations.Add(ms);
_totalMilliseconds += ms;
if (ms < _minMilliseconds)
_minMilliseconds = ms;
if (ms > _maxMilliseconds)
_maxMilliseconds = ms;
if (_durations.Count > 1000)
{
_durations.RemoveAt(0);
}
}
}
public MetricsStatistics GetStatistics()
{
lock (_lock)
{
if (_totalCount == 0)
{
return new MetricsStatistics();
}
var sortedDurations = _durations.OrderBy(d => d).ToList();
var p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1;
p95Index = Math.Max(0, p95Index);
return new MetricsStatistics
{
TotalCount = _totalCount,
SuccessCount = _successCount,
SuccessRate = (double)_successCount / _totalCount,
AverageMilliseconds = _totalMilliseconds / _totalCount,
MinMilliseconds = _minMilliseconds,
MaxMilliseconds = _maxMilliseconds,
Percentile95Milliseconds = sortedDurations[p95Index]
};
}
}
}
/// <summary>
/// Tracks per-operation performance metrics with periodic logging.
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics
= new ConcurrentDictionary<string, OperationMetrics>(StringComparer.OrdinalIgnoreCase);
private readonly Timer _reportingTimer;
private bool _disposed;
public PerformanceMetrics()
{
_reportingTimer = new Timer(ReportMetrics, null,
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
public ITimingScope BeginOperation(string operationName)
{
return new TimingScope(this, operationName);
}
public OperationMetrics? GetMetrics(string operationName)
{
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
}
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics()
{
return _metrics;
}
public Dictionary<string, MetricsStatistics> GetStatistics()
{
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in _metrics)
{
result[kvp.Key] = kvp.Value.GetStatistics();
}
return result;
}
private void ReportMetrics(object? state)
{
foreach (var kvp in _metrics)
{
var stats = kvp.Value.GetStatistics();
if (stats.TotalCount == 0) continue;
Logger.Information(
"Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " +
"AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}",
kvp.Key, stats.TotalCount, stats.SuccessRate,
stats.AverageMilliseconds, stats.MinMilliseconds,
stats.MaxMilliseconds, stats.Percentile95Milliseconds);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_reportingTimer.Dispose();
ReportMetrics(null);
}
/// <summary>
/// Disposable timing scope that records duration on dispose.
/// </summary>
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _success = true;
private bool _disposed;
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
_operationName = operationName;
_stopwatch = Stopwatch.StartNew();
}
public void SetSuccess(bool success)
{
_success = success;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_stopwatch.Stop();
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
}
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Core.Interceptors;
using GrpcStatus = Grpc.Core.Status;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
@@ -58,21 +59,21 @@ namespace ZB.MOM.WW.LmxProxy.Host.Security
if (string.IsNullOrEmpty(apiKey))
{
Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing x-api-key header"));
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Missing x-api-key header"));
}
var key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Log.Warning("Request rejected: invalid API key for {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid API key"));
}
// Check write authorization
if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite)
{
Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method);
throw new RpcException(new Status(StatusCode.PermissionDenied,
throw new RpcException(new GrpcStatus(StatusCode.PermissionDenied,
"Write operations require a ReadWrite API key"));
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Status
{
public class StatusData
{
public DateTime Timestamp { get; set; }
public string ServiceName { get; set; } = "";
public string Version { get; set; } = "";
public ConnectionStatus Connection { get; set; } = new ConnectionStatus();
public SubscriptionStatus Subscriptions { get; set; } = new SubscriptionStatus();
public PerformanceStatus Performance { get; set; } = new PerformanceStatus();
public HealthInfo Health { get; set; } = new HealthInfo();
public HealthInfo? DetailedHealth { get; set; }
}
public class ConnectionStatus
{
public bool IsConnected { get; set; }
public string State { get; set; } = "";
public string NodeName { get; set; } = "";
public string GalaxyName { get; set; } = "";
}
public class SubscriptionStatus
{
public int TotalClients { get; set; }
public int TotalTags { get; set; }
public int ActiveSubscriptions { get; set; }
}
public class PerformanceStatus
{
public long TotalOperations { get; set; }
public double AverageSuccessRate { get; set; }
public Dictionary<string, OperationStatus> Operations { get; set; }
= new Dictionary<string, OperationStatus>();
}
public class OperationStatus
{
public long TotalCount { get; set; }
public double SuccessRate { get; set; }
public double AverageMilliseconds { get; set; }
public double MinMilliseconds { get; set; }
public double MaxMilliseconds { get; set; }
public double Percentile95Milliseconds { get; set; }
}
public class HealthInfo
{
public string Status { get; set; } = "";
public string Description { get; set; } = "";
public Dictionary<string, string> Data { get; set; } = new Dictionary<string, string>();
}
}

View File

@@ -0,0 +1,302 @@
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
};
// 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
sb.AppendLine(" <div class=\"grid-item\"><div class=\"card card-green\">");
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(" </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();
}
}
}

View File

@@ -0,0 +1,215 @@
using System;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Status
{
/// <summary>
/// HTTP status server providing an HTML dashboard, JSON API, and health endpoint.
/// </summary>
public class StatusWebServer : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
private readonly WebServerConfiguration _configuration;
private readonly StatusReportService _statusReportService;
private HttpListener? _httpListener;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _listenerTask;
private bool _disposed;
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService)
{
_configuration = configuration;
_statusReportService = statusReportService;
}
public bool Start()
{
if (!_configuration.Enabled)
{
Logger.Information("Status web server is disabled");
return true;
}
try
{
_httpListener = new HttpListener();
var prefix = _configuration.Prefix ?? $"http://+:{_configuration.Port}/";
if (!prefix.EndsWith("/"))
prefix += "/";
_httpListener.Prefixes.Add(prefix);
_httpListener.Start();
_cancellationTokenSource = new CancellationTokenSource();
_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token));
Logger.Information("Status web server started on {Prefix}", prefix);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to start status web server");
return false;
}
}
public bool Stop()
{
if (!_configuration.Enabled || _httpListener == null)
return true;
try
{
_cancellationTokenSource?.Cancel();
if (_listenerTask != null)
{
_listenerTask.Wait(TimeSpan.FromSeconds(5));
}
_httpListener.Stop();
_httpListener.Close();
Logger.Information("Status web server stopped");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error stopping status web server");
return false;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
_cancellationTokenSource?.Dispose();
if (_httpListener != null)
{
((IDisposable)_httpListener).Dispose();
}
}
private async Task HandleRequestsAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening)
{
try
{
var context = await _httpListener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context));
}
catch (ObjectDisposedException)
{
// Expected during shutdown
break;
}
catch (HttpListenerException ex) when (ex.ErrorCode == 995)
{
// ERROR_OPERATION_ABORTED — expected during shutdown
break;
}
catch (Exception ex)
{
Logger.Error(ex, "Error accepting HTTP request");
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
try
{
if (context.Request.HttpMethod != "GET")
{
context.Response.StatusCode = 405;
await WriteResponseAsync(context.Response, "Method Not Allowed", "text/plain");
return;
}
var path = context.Request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/";
switch (path)
{
case "/":
await HandleStatusPageAsync(context.Response);
break;
case "/api/status":
await HandleStatusApiAsync(context.Response);
break;
case "/api/health":
await HandleHealthApiAsync(context.Response);
break;
default:
context.Response.StatusCode = 404;
await WriteResponseAsync(context.Response, "Not Found", "text/plain");
break;
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request");
try
{
context.Response.StatusCode = 500;
await WriteResponseAsync(context.Response, "Internal Server Error", "text/plain");
}
catch
{
// Ignore errors writing error response
}
}
}
private async Task HandleStatusPageAsync(HttpListenerResponse response)
{
var html = await _statusReportService.GenerateHtmlReportAsync();
await WriteResponseAsync(response, html, "text/html; charset=utf-8");
}
private async Task HandleStatusApiAsync(HttpListenerResponse response)
{
var json = await _statusReportService.GenerateJsonReportAsync();
await WriteResponseAsync(response, json, "application/json; charset=utf-8");
}
private async Task HandleHealthApiAsync(HttpListenerResponse response)
{
var isHealthy = await _statusReportService.IsHealthyAsync();
if (isHealthy)
{
response.StatusCode = 200;
await WriteResponseAsync(response, "OK", "text/plain");
}
else
{
response.StatusCode = 503;
await WriteResponseAsync(response, "UNHEALTHY", "text/plain");
}
}
private static async Task WriteResponseAsync(
HttpListenerResponse response, string content, string contentType)
{
response.ContentType = contentType;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
var buffer = Encoding.UTF8.GetBytes(content);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
}
}