Replace JoeAppEngine tags with TestChildObject tags (TestBool, TestInt, TestFloat, TestDouble, TestString, TestDateTime, and array variants) in Phase 4 and Phase 7 plans. These tags cover all TypedValue oneof cases for comprehensive v2 testing.
26 KiB
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
- 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. - Host targets .NET Framework 4.8, x86 — all code must use C# 9.0 language features maximum (
LangVersionis9.0in the csproj). No file-scoped namespaces, norequiredkeyword, no collection expressions in Host code. - No new NuGet packages — all required packages are already in the Host
.csproj(Microsoft.Extensions.Diagnostics.HealthChecks,Serilog,System.Threading.Channels,System.Text.Jsonvia framework). - Namespace:
ZB.MOM.WW.LmxProxy.Hostwith 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). - All COM operations are on the STA thread — health checks that read test tags must go through
MxAccessClient.ReadAsync(), never directly touching COM objects. - Build must pass after each step:
dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86 - 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)
namespace ZB.MOM.WW.LmxProxy.Host.Metrics
{
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) { ... }
public MetricsStatistics GetStatistics() { ... }
}
}
Implementation details:
Record(TimeSpan duration, bool success): Insidelock (_lock), increment_totalCount, conditionally increment_successCount, addduration.TotalMillisecondsto_durationslist, update_totalMilliseconds,_minMilliseconds,_maxMilliseconds. If_durations.Count > 1000, call_durations.RemoveAt(0)to maintain rolling buffer.GetStatistics(): Insidelock (_lock), return early with emptyMetricsStatisticsif_totalCount == 0. Otherwise sort_durations, compute p95 index as(int)Math.Ceiling(sortedDurations.Count * 0.95) - 1, clamp toMath.Max(0, p95Index).
1.2 MetricsStatistics
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
public interface ITimingScope : IDisposable
{
void SetSuccess(bool success);
}
TimingScope is a private nested class inside PerformanceMetrics:
- Constructor takes
PerformanceMetrics metrics, string operationName, starts aStopwatch. SetSuccess(bool success)stores the flag (defaulttrue).Dispose(): stops stopwatch, calls_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success). Guard against double-dispose with_disposedflag.
1.4 PerformanceMetrics class
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics = new ConcurrentDictionary<string, OperationMetrics>();
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<string, OperationMetrics> GetAllMetrics() { ... }
public Dictionary<string, MetricsStatistics> 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
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
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) { ... }
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default) { ... }
}
Dependencies imported:
ZB.MOM.WW.LmxProxy.Host.DomainforIScadaClient,ConnectionStateZB.MOM.WW.LmxProxy.Host.ServicesforSubscriptionManager(if still in that namespace after Phase 2/3; adjust import to match actual location)ZB.MOM.WW.LmxProxy.Host.MetricsforPerformanceMetricsMicrosoft.Extensions.Diagnostics.HealthChecksforIHealthCheck,HealthCheckResult,HealthCheckContext
CheckHealthAsync logic:
- Create
Dictionary<string, object> data. - Read
_scadaClient.IsConnectedand_scadaClient.ConnectionStateintodata["scada_connected"]anddata["scada_connection_state"]. - Get subscription stats via
_subscriptionManager.GetSubscriptionStats()— storeTotalClients,TotalTagsin data. - Iterate
_performanceMetrics.GetAllMetrics()to computetotalOperationsandaverageSuccessRate. - Store
total_operationsandaverage_success_ratein data. - 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)
- If
- Wrap everything in try/catch — on exception return
Unhealthywith exception details.
2.2 DetailedHealthCheckService
In the same file or a separate file src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs:
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") { ... }
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default) { ... }
}
CheckHealthAsync logic:
- If
!_scadaClient.IsConnected→ returnUnhealthy. - Try
Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken). - If
vtq.Quality != Quality.Good→ returnDegradedwith quality info. - If
DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5)→ returnDegraded(stale data). - Otherwise →
Healthy. - Catch read exceptions → return
Degraded("Could not read test tag"). - Catch all exceptions → return
Unhealthy.
2.3 Verify build
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):
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>();
}
3.2 StatusReportService
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) { ... }
public async Task<string> GenerateHtmlReportAsync() { ... }
public async Task<string> GenerateJsonReportAsync() { ... }
public async Task<bool> IsHealthyAsync() { ... }
private async Task<StatusData> 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",VersionfromAssembly.GetExecutingAssembly().GetName().Version. - Connection info from
_scadaClient.IsConnected,_scadaClient.ConnectionState. - Subscription stats from
_subscriptionManager.GetSubscriptionStats(). - Performance stats from
_performanceMetrics.GetStatistics()— include P95 in theOperationStatus. - 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
StringBuilderto 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).
<meta http-equiv="refresh" content="30">for auto-refresh.- Last updated timestamp at the bottom.
IsHealthyAsync:
- Run basic health check, return
result.Status == HealthStatus.Healthy.
3.3 Verify build
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
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) { ... }
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()
- If
!_configuration.Enabled, log info and returntrue. - Create
HttpListener, add prefix_configuration.Prefix ?? $"http://+:{_configuration.Port}/"(ensure trailing/). - Call
_httpListener.Start(). - Create
_cancellationTokenSource = new CancellationTokenSource(). - Start
_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token)). - On exception, log error and return
false.
4.2 Stop()
- If not enabled or listener is null, return
true. - Cancel
_cancellationTokenSource. - Wait for
_listenerTaskwith 5-second timeout. - Stop and close
_httpListener.
4.3 HandleRequestsAsync
- Loop while not cancelled and listener is listening.
await _httpListener.GetContextAsync()— on success, spawnTask.Runto handle.- Catch
ObjectDisposedExceptionandHttpListenerException(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, addCache-Control: no-cache, no-store, must-revalidate,Pragma: no-cache,Expires: 0. - Convert content to UTF-8 bytes, set
ContentLength64, write toresponse.OutputStream.
4.6 Dispose
- Guard with
_disposedflag. CallStop(). Dispose_cancellationTokenSourceand close_httpListener.
4.7 Verify build
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
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
private PerformanceMetrics? _performanceMetrics;
private HealthCheckService? _healthCheckService;
private DetailedHealthCheckService? _detailedHealthCheckService;
private StatusReportService? _statusReportService;
private StatusWebServer? _statusWebServer;
5.3 In Start(), after SessionManager and SubscriptionManager creation
// 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
// 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
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:
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:
<TargetFramework>net48</TargetFramework><PlatformTarget>x86</PlatformTarget><LangVersion>9.0</LangVersion>- Add
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\ZB.MOM.WW.LmxProxy.Host.csproj" /> - Add
<PackageReference Include="xunit" Version="2.9.3" /> - Add
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> - Add
<PackageReference Include="NSubstitute" Version="5.3.0" />(for mocking IScadaClient) - Add
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
Also add to solution in ZB.MOM.WW.LmxProxy.slnx:
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj" />
</Folder>
6.1 PerformanceMetrics Tests
File: tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs
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).
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
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 <meta http-equiv="refresh" content="30">
[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
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:
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:
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
PerformanceMetricsclass withOperationMetrics,MetricsStatistics,ITimingScopeinsrc/ZB.MOM.WW.LmxProxy.Host/Metrics/HealthCheckServiceandDetailedHealthCheckServiceinsrc/ZB.MOM.WW.LmxProxy.Host/Health/StatusReportServicewith data model classes insrc/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServerwith HTML dashboard, JSON status, and health endpoints insrc/ZB.MOM.WW.LmxProxy.Host/Status/- All components wired into
LmxProxyService.Start()/Stop() ScadaGrpcServiceusesPerformanceMetrics.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