diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs index deb4879..74200dc 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs @@ -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 { diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs new file mode 100644 index 0000000..7e94ba1 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs @@ -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 +{ + /// + /// Detailed health check: reads a test tag, checks quality and timestamp staleness. + /// + public class DetailedHealthCheckService : IHealthCheck + { + private static readonly ILogger Logger = Log.ForContext(); + + private readonly IScadaClient _scadaClient; + private readonly string _testTagAddress; + + public DetailedHealthCheckService( + IScadaClient scadaClient, + string testTagAddress = "TestChildObject.TestBool") + { + _scadaClient = scadaClient; + _testTagAddress = testTagAddress; + } + + public async Task 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 + { + { "test_tag", _testTagAddress }, + { "error", ex.Message } + }); + } + + var data = new Dictionary + { + { "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); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs new file mode 100644 index 0000000..9b8e3a9 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs @@ -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 +{ + /// + /// Basic health check: connection state, success rate, client count. + /// + public class HealthCheckService : IHealthCheck + { + private static readonly ILogger Logger = Log.ForContext(); + + 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 CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var data = new Dictionary(); + + 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)); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs index 80f42ae..31025e6 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs @@ -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..."); diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs new file mode 100644 index 0000000..8488297 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs @@ -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 +{ + /// + /// Disposable scope returned by . + /// + public interface ITimingScope : IDisposable + { + void SetSuccess(bool success); + } + + /// + /// Statistics snapshot for a single operation type. + /// + 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; } + } + + /// + /// Per-operation timing and success tracking with a rolling buffer for percentile computation. + /// + public class OperationMetrics + { + private readonly List _durations = new List(); + 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] + }; + } + } + } + + /// + /// Tracks per-operation performance metrics with periodic logging. + /// + public class PerformanceMetrics : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + + private readonly ConcurrentDictionary _metrics + = new ConcurrentDictionary(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 GetAllMetrics() + { + return _metrics; + } + + public Dictionary GetStatistics() + { + var result = new Dictionary(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); + } + + /// + /// Disposable timing scope that records duration on dispose. + /// + 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); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs index 93c1416..2001704 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs @@ -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")); } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs new file mode 100644 index 0000000..1a2a193 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs @@ -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 Operations { get; set; } + = new Dictionary(); + } + + 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 Data { get; set; } = new Dictionary(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs new file mode 100644 index 0000000..d8c1ee3 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs @@ -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 +{ + /// + /// Aggregates health, metrics, and subscription data into status reports. + /// + public class StatusReportService + { + private static readonly ILogger Logger = Log.ForContext(); + + 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 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 GenerateJsonReportAsync() + { + var statusData = await CollectStatusDataAsync(); + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + return JsonConvert.SerializeObject(statusData, settings); + } + + public async Task IsHealthyAsync() + { + var result = await _healthCheckService.CheckHealthAsync(new HealthCheckContext()); + return result.Status == HealthStatus.Healthy; + } + + private async Task 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(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" LmxProxy Status"); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("

LmxProxy Status Dashboard

"); + + // Connection card + var connClass = statusData.Connection.IsConnected ? "card-green" : "card-red"; + sb.AppendLine($"
"); + sb.AppendLine($"
"); + sb.AppendLine("

Connection

"); + sb.AppendLine($"

Connected: {statusData.Connection.IsConnected}

"); + sb.AppendLine($"

State: {statusData.Connection.State}

"); + if (!string.IsNullOrEmpty(statusData.Connection.NodeName)) + sb.AppendLine($"

Node: {statusData.Connection.NodeName}

"); + if (!string.IsNullOrEmpty(statusData.Connection.GalaxyName)) + sb.AppendLine($"

Galaxy: {statusData.Connection.GalaxyName}

"); + sb.AppendLine("
"); + + // Health card + var healthClass = GetHealthCardClass(statusData.Health.Status); + var healthCss = GetHealthStatusCss(statusData.Health.Status); + sb.AppendLine($"
"); + sb.AppendLine("

Health

"); + sb.AppendLine($"

{statusData.Health.Status}

"); + sb.AppendLine($"

{statusData.Health.Description}

"); + sb.AppendLine("
"); + + // Subscriptions card + sb.AppendLine("
"); + sb.AppendLine("

Subscriptions

"); + sb.AppendLine($"

Clients: {statusData.Subscriptions.TotalClients}

"); + sb.AppendLine($"

Tags: {statusData.Subscriptions.TotalTags}

"); + sb.AppendLine($"

Active: {statusData.Subscriptions.ActiveSubscriptions}

"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + // Operations table + if (statusData.Performance.Operations.Count > 0) + { + sb.AppendLine("
"); + sb.AppendLine("

Operations

"); + sb.AppendLine(" "); + sb.AppendLine(" "); + + foreach (var op in statusData.Performance.Operations) + { + sb.AppendLine($" " + + $"" + + $"" + + $"" + + $"" + + $"" + + $"" + + $"" + + $""); + } + + sb.AppendLine("
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)P95 (ms)
{op.Key}{op.Value.TotalCount}{op.Value.SuccessRate:P1}{op.Value.AverageMilliseconds:F1}{op.Value.MinMilliseconds:F1}{op.Value.MaxMilliseconds:F1}{op.Value.Percentile95Milliseconds:F1}
"); + sb.AppendLine("
"); + } + + // Detailed health (if available) + if (statusData.DetailedHealth != null) + { + var detailedClass = GetHealthCardClass(statusData.DetailedHealth.Status); + var detailedCss = GetHealthStatusCss(statusData.DetailedHealth.Status); + sb.AppendLine($"
"); + sb.AppendLine("

Detailed Health Check

"); + sb.AppendLine($"

{statusData.DetailedHealth.Status}

"); + sb.AppendLine($"

{statusData.DetailedHealth.Description}

"); + sb.AppendLine("
"); + } + + sb.AppendLine($"
Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC | Service: {statusData.ServiceName} v{statusData.Version}
"); + sb.AppendLine(""); + sb.AppendLine(""); + 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(""); + sb.AppendLine("LmxProxy Status - Error"); + sb.AppendLine(""); + sb.AppendLine("

Error generating status report

"); + sb.AppendLine($"

{ex.Message}

"); + sb.AppendLine(""); + return sb.ToString(); + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs new file mode 100644 index 0000000..a3b44f7 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs @@ -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 +{ + /// + /// HTTP status server providing an HTML dashboard, JSON API, and health endpoint. + /// + public class StatusWebServer : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + + 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(); + } + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs new file mode 100644 index 0000000..8482422 --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; +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.Tests.Health +{ + public class HealthCheckServiceTests + { + private class FakeScadaClient : IScadaClient + { + public bool IsConnected { get; set; } = true; + public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected; + public event EventHandler? ConnectionStateChanged; + public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task ReadAsync(string address, CancellationToken ct = default) => + Task.FromResult(Vtq.Good(42.0)); + public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => + Task.FromResult>(new Dictionary()); + public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; + public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; + public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagTag, object flagValue, + int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => + Task.FromResult((false, 0)); + public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => + Task.FromResult(new FakeHandle()); + public ValueTask DisposeAsync() => default; + internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!); + private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; } + } + + [Fact] + public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics() + { + var client = new FakeScadaClient { IsConnected = true, ConnectionState = ConnectionState.Connected }; + using var sm = new SubscriptionManager(client); + using var pm = new PerformanceMetrics(); + pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true); + + var svc = new HealthCheckService(client, sm, pm); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task ReturnsUnhealthy_WhenNotConnected() + { + var client = new FakeScadaClient { IsConnected = false, ConnectionState = ConnectionState.Disconnected }; + using var sm = new SubscriptionManager(client); + using var pm = new PerformanceMetrics(); + + var svc = new HealthCheckService(client, sm, pm); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Unhealthy); + result.Description.Should().Contain("not connected"); + } + + [Fact] + public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent() + { + var client = new FakeScadaClient { IsConnected = true }; + using var sm = new SubscriptionManager(client); + using var pm = new PerformanceMetrics(); + + // Record 200 operations with 40% success rate + for (int i = 0; i < 80; i++) + pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true); + for (int i = 0; i < 120; i++) + pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false); + + var svc = new HealthCheckService(client, sm, pm); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Degraded); + result.Description.Should().Contain("success rate"); + } + + [Fact] + public async Task ReturnsDegraded_WhenClientCountOver100() + { + var client = new FakeScadaClient { IsConnected = true }; + using var sm = new SubscriptionManager(client); + using var pm = new PerformanceMetrics(); + + // Create 101 subscriptions to exceed the threshold + for (int i = 0; i < 101; i++) + { + using var cts = new CancellationTokenSource(); + sm.Subscribe("client-" + i, new[] { "tag1" }, cts.Token); + } + + var svc = new HealthCheckService(client, sm, pm); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Degraded); + result.Description.Should().Contain("client count"); + } + + [Fact] + public async Task DoesNotFlagLowSuccessRate_Under100Operations() + { + var client = new FakeScadaClient { IsConnected = true }; + using var sm = new SubscriptionManager(client); + using var pm = new PerformanceMetrics(); + + // Record 50 operations with 0% success rate (under 100 threshold) + for (int i = 0; i < 50; i++) + pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false); + + var svc = new HealthCheckService(client, sm, pm); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Healthy); + } + } + + public class DetailedHealthCheckServiceTests + { + private class FakeScadaClient : IScadaClient + { + public bool IsConnected { get; set; } = true; + public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected; + public Vtq? ReadResult { get; set; } + public Exception? ReadException { get; set; } + public event EventHandler? ConnectionStateChanged; + public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task ReadAsync(string address, CancellationToken ct = default) + { + if (ReadException != null) throw ReadException; + return Task.FromResult(ReadResult ?? Vtq.Good(true)); + } + public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => + Task.FromResult>(new Dictionary()); + public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; + public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; + public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagTag, object flagValue, + int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => + Task.FromResult((false, 0)); + public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => + Task.FromResult(new FakeHandle()); + public ValueTask DisposeAsync() => default; + internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!); + private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; } + } + + [Fact] + public async Task ReturnsUnhealthy_WhenNotConnected() + { + var client = new FakeScadaClient { IsConnected = false }; + var svc = new DetailedHealthCheckService(client); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public async Task ReturnsHealthy_WhenTestTagGoodAndRecent() + { + var client = new FakeScadaClient + { + IsConnected = true, + ReadResult = Vtq.New(true, DateTime.UtcNow, Quality.Good) + }; + var svc = new DetailedHealthCheckService(client); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task ReturnsDegraded_WhenTestTagQualityNotGood() + { + var client = new FakeScadaClient + { + IsConnected = true, + ReadResult = Vtq.New(true, DateTime.UtcNow, Quality.Uncertain) + }; + var svc = new DetailedHealthCheckService(client); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Degraded); + } + + [Fact] + public async Task ReturnsDegraded_WhenTestTagTimestampStale() + { + var client = new FakeScadaClient + { + IsConnected = true, + ReadResult = Vtq.New(true, DateTime.UtcNow.AddMinutes(-10), Quality.Good) + }; + var svc = new DetailedHealthCheckService(client); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Degraded); + result.Description.Should().Contain("stale"); + } + + [Fact] + public async Task ReturnsDegraded_WhenTestTagReadThrows() + { + var client = new FakeScadaClient + { + IsConnected = true, + ReadException = new InvalidOperationException("COM error") + }; + var svc = new DetailedHealthCheckService(client); + + var result = await svc.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Degraded); + result.Description.Should().Contain("Could not read test tag"); + } + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs new file mode 100644 index 0000000..1c36f5c --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Metrics; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics +{ + public class PerformanceMetricsTests + { + [Fact] + public void RecordOperation_TracksCountAndDuration() + { + using var metrics = new PerformanceMetrics(); + + for (int i = 0; i < 5; i++) + { + metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true); + } + + var stats = metrics.GetStatistics(); + stats.Should().ContainKey("TestOp"); + stats["TestOp"].TotalCount.Should().Be(5); + } + + [Fact] + public void RecordOperation_TracksSuccessAndFailure() + { + using var metrics = new PerformanceMetrics(); + + for (int i = 0; i < 3; i++) + { + metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true); + } + for (int i = 0; i < 2; i++) + { + metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), false); + } + + var stats = metrics.GetStatistics(); + stats["TestOp"].SuccessRate.Should().BeApproximately(0.6, 0.001); + } + + [Fact] + public void GetStatistics_CalculatesP95Correctly() + { + using var metrics = new PerformanceMetrics(); + + for (int i = 1; i <= 100; i++) + { + metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true); + } + + var stats = metrics.GetStatistics(); + stats["TestOp"].Percentile95Milliseconds.Should().BeApproximately(95.0, 1.0); + } + + [Fact] + public void RollingBuffer_CapsAt1000Samples() + { + using var metrics = new PerformanceMetrics(); + + for (int i = 0; i < 1500; i++) + { + metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true); + } + + var stats = metrics.GetStatistics(); + // TotalCount tracks all 1500 but percentile is computed from the last 1000 + stats["TestOp"].TotalCount.Should().Be(1500); + // The rolling buffer should have entries from 500-1499 + // P95 of 500..1499 should be around 1449 + stats["TestOp"].Percentile95Milliseconds.Should().BeGreaterThan(1000); + } + + [Fact] + public void BeginOperation_RecordsDurationOnDispose() + { + using var metrics = new PerformanceMetrics(); + + using (var scope = metrics.BeginOperation("TestOp")) + { + System.Threading.Thread.Sleep(50); + } + + var stats = metrics.GetStatistics(); + stats.Should().ContainKey("TestOp"); + stats["TestOp"].TotalCount.Should().Be(1); + stats["TestOp"].AverageMilliseconds.Should().BeGreaterOrEqualTo(40); + } + + [Fact] + public void TimingScope_DefaultsToSuccess() + { + using var metrics = new PerformanceMetrics(); + + using (metrics.BeginOperation("TestOp")) + { + // Do nothing — default is success + } + + var stats = metrics.GetStatistics(); + stats["TestOp"].SuccessCount.Should().Be(1); + } + + [Fact] + public void TimingScope_RespectsSetSuccessFalse() + { + using var metrics = new PerformanceMetrics(); + + using (var scope = metrics.BeginOperation("TestOp")) + { + scope.SetSuccess(false); + } + + var stats = metrics.GetStatistics(); + stats["TestOp"].SuccessCount.Should().Be(0); + stats["TestOp"].TotalCount.Should().Be(1); + } + + [Fact] + public void GetMetrics_ReturnsNullForUnknownOperation() + { + using var metrics = new PerformanceMetrics(); + + var result = metrics.GetMetrics("DoesNotExist"); + + result.Should().BeNull(); + } + + [Fact] + public void GetAllMetrics_ReturnsAllTrackedOperations() + { + using var metrics = new PerformanceMetrics(); + + metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true); + metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20), true); + metrics.RecordOperation("Subscribe", TimeSpan.FromMilliseconds(5), true); + + var all = metrics.GetAllMetrics(); + all.Should().ContainKey("Read"); + all.Should().ContainKey("Write"); + all.Should().ContainKey("Subscribe"); + all.Count.Should().Be(3); + } + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs new file mode 100644 index 0000000..9df6708 --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using Xunit; +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.Status; +using ZB.MOM.WW.LmxProxy.Host.Subscriptions; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status +{ + public class StatusReportServiceTests + { + private class FakeScadaClient : IScadaClient + { + public bool IsConnected { get; set; } = true; + public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected; + public event EventHandler? ConnectionStateChanged; + public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task ReadAsync(string address, CancellationToken ct = default) => + Task.FromResult(Vtq.Good(42.0)); + public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => + Task.FromResult>(new Dictionary()); + public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; + public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; + public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagTag, object flagValue, + int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => + Task.FromResult((false, 0)); + public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => + Task.FromResult(new FakeHandle()); + public ValueTask DisposeAsync() => default; + internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!); + private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; } + } + + private (StatusReportService svc, PerformanceMetrics pm, SubscriptionManager sm) CreateService( + bool connected = true) + { + var client = new FakeScadaClient + { + IsConnected = connected, + ConnectionState = connected ? ConnectionState.Connected : ConnectionState.Disconnected + }; + var sm = new SubscriptionManager(client); + var pm = new PerformanceMetrics(); + var health = new HealthCheckService(client, sm, pm); + var detailed = new DetailedHealthCheckService(client); + var svc = new StatusReportService(client, sm, pm, health, detailed); + return (svc, pm, sm); + } + + [Fact] + public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson() + { + var (svc, pm, sm) = CreateService(); + using (pm) using (sm) + { + var json = await svc.GenerateJsonReportAsync(); + + json.Should().Contain("\"serviceName\""); + json.Should().Contain("\"connection\""); + json.Should().Contain("\"isConnected\""); + } + } + + [Fact] + public async Task GenerateHtmlReportAsync_ContainsAutoRefresh() + { + var (svc, pm, sm) = CreateService(); + using (pm) using (sm) + { + var html = await svc.GenerateHtmlReportAsync(); + + html.Should().Contain(""); + } + } + + [Fact] + public async Task IsHealthyAsync_ReturnsTrueWhenHealthy() + { + var (svc, pm, sm) = CreateService(connected: true); + using (pm) using (sm) + { + var result = await svc.IsHealthyAsync(); + + result.Should().BeTrue(); + } + } + + [Fact] + public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy() + { + var (svc, pm, sm) = CreateService(connected: false); + using (pm) using (sm) + { + var result = await svc.IsHealthyAsync(); + + result.Should().BeFalse(); + } + } + + [Fact] + public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics() + { + var (svc, pm, sm) = CreateService(); + using (pm) using (sm) + { + pm.RecordOperation("Read", TimeSpan.FromMilliseconds(15), true); + pm.RecordOperation("Write", TimeSpan.FromMilliseconds(25), true); + + var json = await svc.GenerateJsonReportAsync(); + var parsed = JObject.Parse(json); + + var operations = parsed["performance"]?["operations"]; + operations.Should().NotBeNull(); + // Newtonsoft CamelCasePropertyNamesContractResolver camelCases dictionary keys + operations!["read"].Should().NotBeNull(); + operations!["write"].Should().NotBeNull(); + ((long)operations!["read"]!["totalCount"]!).Should().Be(1); + } + } + } +}