# Phase 4: Host Health, Metrics & Status Server — Implementation Plan **Date**: 2026-03-21 **Prerequisites**: Phase 3 complete and passing (gRPC server, Security, Configuration, Service Hosting all functional) **Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` ## Guardrails 1. **This is a v2 rebuild** — do not copy code from the v1 reference in `src-reference/`. Write fresh implementations guided by the design docs and the reference code's structure. 2. **Host targets .NET Framework 4.8, x86** — all code must use C# 9.0 language features maximum (`LangVersion` is `9.0` in the csproj). No file-scoped namespaces, no `required` keyword, no collection expressions in Host code. 3. **No new NuGet packages** — all required packages are already in the Host `.csproj` (`Microsoft.Extensions.Diagnostics.HealthChecks`, `Serilog`, `System.Threading.Channels`, `System.Text.Json` via framework). 4. **Namespace**: `ZB.MOM.WW.LmxProxy.Host` with sub-namespaces matching folder structure (e.g., `ZB.MOM.WW.LmxProxy.Host.Health`, `ZB.MOM.WW.LmxProxy.Host.Metrics`, `ZB.MOM.WW.LmxProxy.Host.Status`). 5. **All COM operations are on the STA thread** — health checks that read test tags must go through `MxAccessClient.ReadAsync()`, never directly touching COM objects. 6. **Build must pass after each step**: `dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86` 7. **Tests run on windev**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86` ## Step 1: Create PerformanceMetrics **File**: `src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs` Create the `PerformanceMetrics` class in namespace `ZB.MOM.WW.LmxProxy.Host.Metrics`. ### 1.1 OperationMetrics (nested or separate class in same file) ```csharp namespace ZB.MOM.WW.LmxProxy.Host.Metrics { 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) { ... } public MetricsStatistics GetStatistics() { ... } } } ``` Implementation details: - `Record(TimeSpan duration, bool success)`: Inside `lock (_lock)`, increment `_totalCount`, conditionally increment `_successCount`, add `duration.TotalMilliseconds` to `_durations` list, update `_totalMilliseconds`, `_minMilliseconds`, `_maxMilliseconds`. If `_durations.Count > 1000`, call `_durations.RemoveAt(0)` to maintain rolling buffer. - `GetStatistics()`: Inside `lock (_lock)`, return early with empty `MetricsStatistics` if `_totalCount == 0`. Otherwise sort `_durations`, compute p95 index as `(int)Math.Ceiling(sortedDurations.Count * 0.95) - 1`, clamp to `Math.Max(0, p95Index)`. ### 1.2 MetricsStatistics ```csharp 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; } } ``` ### 1.3 ITimingScope interface and TimingScope implementation ```csharp public interface ITimingScope : IDisposable { void SetSuccess(bool success); } ``` `TimingScope` is a private nested class inside `PerformanceMetrics`: - Constructor takes `PerformanceMetrics metrics, string operationName`, starts a `Stopwatch`. - `SetSuccess(bool success)` stores the flag (default `true`). - `Dispose()`: stops stopwatch, calls `_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success)`. Guard against double-dispose with `_disposed` flag. ### 1.4 PerformanceMetrics class ```csharp public class PerformanceMetrics : IDisposable { private static readonly ILogger Logger = Log.ForContext(); private readonly ConcurrentDictionary _metrics = new ConcurrentDictionary(); 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) { ... } public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName); public OperationMetrics? GetMetrics(string operationName) { ... } public IReadOnlyDictionary GetAllMetrics() { ... } public Dictionary GetStatistics() { ... } private void ReportMetrics(object? state) { ... } // Log each operation's stats at Information level public void Dispose() { ... } // Dispose timer, call ReportMetrics one final time } ``` `ReportMetrics` iterates `_metrics`, calls `GetStatistics()` on each, logs via Serilog structured logging with properties: `Operation`, `Count`, `SuccessRate`, `AverageMs`, `MinMs`, `MaxMs`, `P95Ms`. ### 1.5 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" ``` ## Step 2: Create HealthCheckService **File**: `src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs` Namespace: `ZB.MOM.WW.LmxProxy.Host.Health` ### 2.1 Basic HealthCheckService ```csharp 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) { ... } public Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { ... } } ``` Dependencies imported: - `ZB.MOM.WW.LmxProxy.Host.Domain` for `IScadaClient`, `ConnectionState` - `ZB.MOM.WW.LmxProxy.Host.Services` for `SubscriptionManager` (if still in that namespace after Phase 2/3; adjust import to match actual location) - `ZB.MOM.WW.LmxProxy.Host.Metrics` for `PerformanceMetrics` - `Microsoft.Extensions.Diagnostics.HealthChecks` for `IHealthCheck`, `HealthCheckResult`, `HealthCheckContext` `CheckHealthAsync` logic: 1. Create `Dictionary data`. 2. Read `_scadaClient.IsConnected` and `_scadaClient.ConnectionState` into `data["scada_connected"]` and `data["scada_connection_state"]`. 3. Get subscription stats via `_subscriptionManager.GetSubscriptionStats()` — store `TotalClients`, `TotalTags` in data. 4. Iterate `_performanceMetrics.GetAllMetrics()` to compute `totalOperations` and `averageSuccessRate`. 5. Store `total_operations` and `average_success_rate` in data. 6. Decision tree: - If `!isConnected` → `HealthCheckResult.Unhealthy("SCADA client is not connected", data: data)` - If `averageSuccessRate < 0.5 && totalOperations > 100` → `HealthCheckResult.Degraded(...)` - If `subscriptionStats.TotalClients > 100` → `HealthCheckResult.Degraded(...)` - Otherwise → `HealthCheckResult.Healthy("LmxProxy is healthy", data)` 7. Wrap everything in try/catch — on exception return `Unhealthy` with exception details. ### 2.2 DetailedHealthCheckService In the same file or a separate file `src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs`: ```csharp 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") { ... } public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { ... } } ``` `CheckHealthAsync` logic: 1. If `!_scadaClient.IsConnected` → return `Unhealthy`. 2. Try `Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken)`. 3. If `vtq.Quality != Quality.Good` → return `Degraded` with quality info. 4. If `DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5)` → return `Degraded` (stale data). 5. Otherwise → `Healthy`. 6. Catch read exceptions → return `Degraded("Could not read test tag")`. 7. Catch all exceptions → return `Unhealthy`. ### 2.3 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" ``` ## Step 3: Create StatusReportService **File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs` Namespace: `ZB.MOM.WW.LmxProxy.Host.Status` ### 3.1 Data model classes Define in the same file (or a separate `StatusModels.cs` in the same folder): ```csharp 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(); } ``` ### 3.2 StatusReportService ```csharp 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) { ... } public async Task GenerateHtmlReportAsync() { ... } public async Task GenerateJsonReportAsync() { ... } public async Task IsHealthyAsync() { ... } private async Task CollectStatusDataAsync() { ... } private static string GenerateHtmlFromStatusData(StatusData statusData) { ... } private static string GenerateErrorHtml(Exception ex) { ... } } ``` `CollectStatusDataAsync`: - Populate `StatusData.Timestamp = DateTime.UtcNow`, `ServiceName = "ZB.MOM.WW.LmxProxy.Host"`, `Version` from `Assembly.GetExecutingAssembly().GetName().Version`. - Connection info from `_scadaClient.IsConnected`, `_scadaClient.ConnectionState`. - Subscription stats from `_subscriptionManager.GetSubscriptionStats()`. - Performance stats from `_performanceMetrics.GetStatistics()` — include P95 in the `OperationStatus`. - Health from `_healthCheckService.CheckHealthAsync(new HealthCheckContext())`. - Detailed health from `_detailedHealthCheckService?.CheckHealthAsync(new HealthCheckContext())` if not null. `GenerateJsonReportAsync`: - Use `System.Text.Json.JsonSerializer.Serialize(statusData, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })`. `GenerateHtmlFromStatusData`: - Use `StringBuilder` to generate self-contained HTML. - Include inline CSS (Bootstrap-like grid, status cards with color-coded left borders). - Color coding: green (#28a745) for Healthy/Connected, yellow (#ffc107) for Degraded, red (#dc3545) for Unhealthy/Disconnected. - Operations table with columns: Operation, Count, Success Rate, Avg (ms), Min (ms), Max (ms), P95 (ms). - `` for auto-refresh. - Last updated timestamp at the bottom. `IsHealthyAsync`: - Run basic health check, return `result.Status == HealthStatus.Healthy`. ### 3.3 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" ``` ## Step 4: Create StatusWebServer **File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs` Namespace: `ZB.MOM.WW.LmxProxy.Host.Status` ```csharp 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) { ... } public bool Start() { ... } public bool Stop() { ... } public void Dispose() { ... } private async Task HandleRequestsAsync(CancellationToken cancellationToken) { ... } private async Task HandleRequestAsync(HttpListenerContext context) { ... } private async Task HandleStatusPageAsync(HttpListenerResponse response) { ... } private async Task HandleStatusApiAsync(HttpListenerResponse response) { ... } private async Task HandleHealthApiAsync(HttpListenerResponse response) { ... } private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType) { ... } } ``` ### 4.1 Start() 1. If `!_configuration.Enabled`, log info and return `true`. 2. Create `HttpListener`, add prefix `_configuration.Prefix ?? $"http://+:{_configuration.Port}/"` (ensure trailing `/`). 3. Call `_httpListener.Start()`. 4. Create `_cancellationTokenSource = new CancellationTokenSource()`. 5. Start `_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token))`. 6. On exception, log error and return `false`. ### 4.2 Stop() 1. If not enabled or listener is null, return `true`. 2. Cancel `_cancellationTokenSource`. 3. Wait for `_listenerTask` with 5-second timeout. 4. Stop and close `_httpListener`. ### 4.3 HandleRequestsAsync - Loop while not cancelled and listener is listening. - `await _httpListener.GetContextAsync()` — on success, spawn `Task.Run` to handle. - Catch `ObjectDisposedException` and `HttpListenerException(995)` as expected shutdown signals. - On other errors, log and delay 1 second before continuing. ### 4.4 HandleRequestAsync routing | Path (lowered) | Handler | |---|---| | `/` | `HandleStatusPageAsync` — calls `_statusReportService.GenerateHtmlReportAsync()`, content type `text/html; charset=utf-8` | | `/api/status` | `HandleStatusApiAsync` — calls `_statusReportService.GenerateJsonReportAsync()`, content type `application/json; charset=utf-8` | | `/api/health` | `HandleHealthApiAsync` — calls `_statusReportService.IsHealthyAsync()`, returns `"OK"` (200) or `"UNHEALTHY"` (503) as `text/plain` | | Non-GET method | Return 405 Method Not Allowed | | Unknown path | Return 404 Not Found | | Exception | Return 500 Internal Server Error | ### 4.5 WriteResponseAsync - Set `Content-Type`, add `Cache-Control: no-cache, no-store, must-revalidate`, `Pragma: no-cache`, `Expires: 0`. - Convert content to UTF-8 bytes, set `ContentLength64`, write to `response.OutputStream`. ### 4.6 Dispose - Guard with `_disposed` flag. Call `Stop()`. Dispose `_cancellationTokenSource` and close `_httpListener`. ### 4.7 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" ``` ## Step 5: Wire into LmxProxyService **File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` This file already exists. Modify the `Start()` method to create and wire the new components. The v2 rebuild should create these fresh, but the wiring pattern follows the same order as the reference. ### 5.1 Add using directives ```csharp using ZB.MOM.WW.LmxProxy.Host.Health; using ZB.MOM.WW.LmxProxy.Host.Metrics; using ZB.MOM.WW.LmxProxy.Host.Status; ``` ### 5.2 Add fields ```csharp private PerformanceMetrics? _performanceMetrics; private HealthCheckService? _healthCheckService; private DetailedHealthCheckService? _detailedHealthCheckService; private StatusReportService? _statusReportService; private StatusWebServer? _statusWebServer; ``` ### 5.3 In Start(), after SessionManager and SubscriptionManager creation ```csharp // Create performance metrics _performanceMetrics = new PerformanceMetrics(); // Create health check services _healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics); _detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient); // Create status report service _statusReportService = new StatusReportService( _scadaClient, _subscriptionManager, _performanceMetrics, _healthCheckService, _detailedHealthCheckService); // Start status web server _statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService); if (!_statusWebServer.Start()) { Logger.Warning("Status web server failed to start — continuing without it"); } ``` ### 5.4 In Stop(), before gRPC server shutdown ```csharp // Stop status web server _statusWebServer?.Stop(); // Dispose performance metrics _performanceMetrics?.Dispose(); ``` ### 5.5 Pass _performanceMetrics to ScadaGrpcService constructor Ensure `ScadaGrpcService` receives `_performanceMetrics` so it can record timings on each RPC call. The gRPC service should call `_performanceMetrics.BeginOperation("Read")` (etc.) and dispose the timing scope at the end of each RPC handler. ### 5.6 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" ``` ## Step 6: Unit Tests **Project**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/` If this project does not exist yet, create it: ```bash ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Host.Tests -o tests/ZB.MOM.WW.LmxProxy.Host.Tests --framework net48" ``` **Csproj adjustments** for `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj`: - `net48` - `x86` - `9.0` - Add `` - Add `` - Add `` - Add `` (for mocking IScadaClient) - Add `` **Also add to solution** in `ZB.MOM.WW.LmxProxy.slnx`: ```xml ``` ### 6.1 PerformanceMetrics Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs` ```csharp namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics { public class PerformanceMetricsTests { [Fact] public void RecordOperation_TracksCountAndDuration() // Record 5 operations, verify GetStatistics returns TotalCount=5 [Fact] public void RecordOperation_TracksSuccessAndFailure() // Record 3 success + 2 failure, verify SuccessRate == 0.6 [Fact] public void GetStatistics_CalculatesP95Correctly() // Record 100 operations with known durations (1ms through 100ms) // Verify P95 is approximately 95ms [Fact] public void RollingBuffer_CapsAt1000Samples() // Record 1500 operations, verify _durations list doesn't exceed 1000 // (test via GetStatistics behavior — TotalCount is 1500 but percentile computed from 1000) [Fact] public void BeginOperation_RecordsDurationOnDispose() // Use BeginOperation, await Task.Delay(50), dispose scope // Verify recorded duration >= 50ms [Fact] public void TimingScope_DefaultsToSuccess() // BeginOperation + dispose without calling SetSuccess // Verify SuccessCount == 1 [Fact] public void TimingScope_RespectsSetSuccessFalse() // BeginOperation, SetSuccess(false), dispose // Verify SuccessCount == 0, TotalCount == 1 [Fact] public void GetMetrics_ReturnsNullForUnknownOperation() [Fact] public void GetAllMetrics_ReturnsAllTrackedOperations() } } ``` ### 6.2 HealthCheckService Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs` Use NSubstitute to mock `IScadaClient`. Create a real `PerformanceMetrics` instance and a real or mock `SubscriptionManager` (depends on Phase 2/3 implementation — if `SubscriptionManager` has an interface, mock it; if not, use the `GetSubscriptionStats()` approach with a concrete instance). ```csharp namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health { public class HealthCheckServiceTests { [Fact] public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics() // Mock: IsConnected=true, ConnectionState=Connected // SubscriptionStats: TotalClients=5, TotalTags=10 // PerformanceMetrics: record some successes // Assert: HealthStatus.Healthy [Fact] public async Task ReturnsUnhealthy_WhenNotConnected() // Mock: IsConnected=false // Assert: HealthStatus.Unhealthy, description contains "not connected" [Fact] public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent() // Mock: IsConnected=true // Record 200 operations with 40% success rate // Assert: HealthStatus.Degraded [Fact] public async Task ReturnsDegraded_WhenClientCountOver100() // Mock: IsConnected=true, SubscriptionStats.TotalClients=150 // Assert: HealthStatus.Degraded [Fact] public async Task DoesNotFlagLowSuccessRate_Under100Operations() // Record 50 operations with 0% success rate // Assert: still Healthy (threshold is > 100 total ops) } public class DetailedHealthCheckServiceTests { [Fact] public async Task ReturnsUnhealthy_WhenNotConnected() [Fact] public async Task ReturnsHealthy_WhenTestTagGoodAndRecent() // Mock ReadAsync returns Good quality with recent timestamp // Assert: Healthy [Fact] public async Task ReturnsDegraded_WhenTestTagQualityNotGood() // Mock ReadAsync returns Uncertain quality // Assert: Degraded [Fact] public async Task ReturnsDegraded_WhenTestTagTimestampStale() // Mock ReadAsync returns Good quality but timestamp 10 minutes ago // Assert: Degraded [Fact] public async Task ReturnsDegraded_WhenTestTagReadThrows() // Mock ReadAsync throws exception // Assert: Degraded } } ``` ### 6.3 StatusReportService Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs` ```csharp namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status { public class StatusReportServiceTests { [Fact] public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson() // Verify JSON contains "serviceName", "connection", "isConnected" (camelCase) [Fact] public async Task GenerateHtmlReportAsync_ContainsAutoRefresh() // Verify HTML contains [Fact] public async Task IsHealthyAsync_ReturnsTrueWhenHealthy() [Fact] public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy() [Fact] public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics() // Record some operations, verify JSON includes operation names and stats } } ``` ### 6.4 Run tests ```bash ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86 --verbosity normal" ``` ## Step 7: Build Verification Run full solution build and tests: ```bash ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" ``` If the test project is .NET 4.8 x86, you may need: ```bash ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx --platform x86 && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86" ``` ## Completion Criteria - [ ] `PerformanceMetrics` class with `OperationMetrics`, `MetricsStatistics`, `ITimingScope` in `src/ZB.MOM.WW.LmxProxy.Host/Metrics/` - [ ] `HealthCheckService` and `DetailedHealthCheckService` in `src/ZB.MOM.WW.LmxProxy.Host/Health/` - [ ] `StatusReportService` with data model classes in `src/ZB.MOM.WW.LmxProxy.Host/Status/` - [ ] `StatusWebServer` with HTML dashboard, JSON status, and health endpoints in `src/ZB.MOM.WW.LmxProxy.Host/Status/` - [ ] All components wired into `LmxProxyService.Start()` / `Stop()` - [ ] `ScadaGrpcService` uses `PerformanceMetrics.BeginOperation()` for Read, ReadBatch, Write, WriteBatch RPCs - [ ] Unit tests for PerformanceMetrics (recording, percentile, rolling buffer, timing scope) - [ ] Unit tests for HealthCheckService (healthy, unhealthy, degraded transitions) - [ ] Unit tests for DetailedHealthCheckService (connected, quality, staleness) - [ ] Unit tests for StatusReportService (JSON format, HTML format, health aggregation) - [ ] Solution builds without errors: `dotnet build ZB.MOM.WW.LmxProxy.slnx` - [ ] All tests pass: `dotnet test`