deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL adapter files, and related docs to deprecated/. Removed LmxProxy registration from DataConnectionFactory, project reference from DCL, protocol option from UI, and cleaned up all requirement docs.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Health check service for monitoring LmxProxy health
|
||||
/// </summary>
|
||||
public class HealthCheckService : IHealthCheck
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
|
||||
private readonly PerformanceMetrics _performanceMetrics;
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
|
||||
public HealthCheckService(
|
||||
IScadaClient scadaClient,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics performanceMetrics)
|
||||
{
|
||||
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
|
||||
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
|
||||
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check SCADA connection
|
||||
bool isConnected = _scadaClient.IsConnected;
|
||||
ConnectionState connectionState = _scadaClient.ConnectionState;
|
||||
data["scada_connected"] = isConnected;
|
||||
data["scada_connection_state"] = connectionState.ToString();
|
||||
|
||||
// Get subscription statistics
|
||||
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
|
||||
data["total_clients"] = subscriptionStats.TotalClients;
|
||||
data["total_tags"] = subscriptionStats.TotalTags;
|
||||
|
||||
// Get performance metrics
|
||||
IReadOnlyDictionary<string, OperationMetrics> metrics = _performanceMetrics.GetAllMetrics();
|
||||
long totalOperations = 0L;
|
||||
double averageSuccessRate = 0.0;
|
||||
|
||||
foreach (OperationMetrics? metric in metrics.Values)
|
||||
{
|
||||
MetricsStatistics stats = metric.GetStatistics();
|
||||
totalOperations += stats.TotalCount;
|
||||
averageSuccessRate += stats.SuccessRate;
|
||||
}
|
||||
|
||||
if (metrics.Count > 0)
|
||||
{
|
||||
averageSuccessRate /= metrics.Count;
|
||||
}
|
||||
|
||||
data["total_operations"] = totalOperations;
|
||||
data["average_success_rate"] = averageSuccessRate;
|
||||
|
||||
// Determine health status
|
||||
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(
|
||||
$"Low success rate: {averageSuccessRate:P}",
|
||||
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));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Health check failed");
|
||||
data["error"] = ex.Message;
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy(
|
||||
"Health check threw an exception",
|
||||
ex,
|
||||
data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed health check that performs additional connectivity tests
|
||||
/// </summary>
|
||||
public class DetailedHealthCheckService : IHealthCheck
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DetailedHealthCheckService>();
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly string _testTagAddress;
|
||||
|
||||
public DetailedHealthCheckService(IScadaClient scadaClient, string testTagAddress = "System.Heartbeat")
|
||||
{
|
||||
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
|
||||
_testTagAddress = testTagAddress;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
try
|
||||
{
|
||||
// Basic connectivity check
|
||||
if (!_scadaClient.IsConnected)
|
||||
{
|
||||
data["connected"] = false;
|
||||
return HealthCheckResult.Unhealthy("SCADA client is not connected", data: data);
|
||||
}
|
||||
|
||||
data["connected"] = true;
|
||||
|
||||
// Try to read a test tag
|
||||
try
|
||||
{
|
||||
Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken);
|
||||
data["test_tag_quality"] = vtq.Quality.ToString();
|
||||
data["test_tag_timestamp"] = vtq.Timestamp;
|
||||
|
||||
if (vtq.Quality != Quality.Good)
|
||||
{
|
||||
return HealthCheckResult.Degraded(
|
||||
$"Test tag quality is {vtq.Quality}",
|
||||
data: data);
|
||||
}
|
||||
|
||||
// Check if timestamp is recent (within last 5 minutes)
|
||||
TimeSpan age = DateTime.UtcNow - vtq.Timestamp;
|
||||
if (age > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
data["timestamp_age_minutes"] = age.TotalMinutes;
|
||||
return HealthCheckResult.Degraded(
|
||||
$"Test tag timestamp is stale ({age.TotalMinutes:F1} minutes old)",
|
||||
data: data);
|
||||
}
|
||||
}
|
||||
catch (Exception readEx)
|
||||
{
|
||||
data["test_tag_error"] = readEx.Message;
|
||||
return HealthCheckResult.Degraded(
|
||||
"Could not read test tag",
|
||||
data: data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("All checks passed", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Detailed health check failed");
|
||||
data["error"] = ex.Message;
|
||||
|
||||
return HealthCheckResult.Unhealthy(
|
||||
"Health check threw an exception",
|
||||
ex,
|
||||
data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides performance metrics tracking for LmxProxy operations
|
||||
/// </summary>
|
||||
public class PerformanceMetrics : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics = new();
|
||||
private readonly Timer _reportingTimer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the PerformanceMetrics class
|
||||
/// </summary>
|
||||
public PerformanceMetrics()
|
||||
{
|
||||
// Report metrics every minute
|
||||
_reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
_reportingTimer?.Dispose();
|
||||
ReportMetrics(null); // Final report
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the execution time of an operation
|
||||
/// </summary>
|
||||
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
|
||||
{
|
||||
OperationMetrics? metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
|
||||
metrics.Record(duration, success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a timing scope for measuring operation duration
|
||||
/// </summary>
|
||||
public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current metrics for a specific operation
|
||||
/// </summary>
|
||||
public OperationMetrics? GetMetrics(string operationName) =>
|
||||
_metrics.TryGetValue(operationName, out OperationMetrics? metrics) ? metrics : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all current metrics
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics() =>
|
||||
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for all operations
|
||||
/// </summary>
|
||||
public Dictionary<string, MetricsStatistics> GetStatistics() =>
|
||||
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetStatistics());
|
||||
|
||||
private void ReportMetrics(object? state)
|
||||
{
|
||||
foreach (KeyValuePair<string, OperationMetrics> kvp in _metrics)
|
||||
{
|
||||
MetricsStatistics stats = kvp.Value.GetStatistics();
|
||||
if (stats.TotalCount > 0)
|
||||
{
|
||||
Logger.Information(
|
||||
"Performance Metrics - {Operation}: Count={Count}, Success={SuccessRate:P}, " +
|
||||
"Avg={AverageMs:F2}ms, Min={MinMs:F2}ms, Max={MaxMs:F2}ms, P95={P95Ms:F2}ms",
|
||||
kvp.Key,
|
||||
stats.TotalCount,
|
||||
stats.SuccessRate,
|
||||
stats.AverageMilliseconds,
|
||||
stats.MinMilliseconds,
|
||||
stats.MaxMilliseconds,
|
||||
stats.Percentile95Milliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timing scope for automatic duration measurement
|
||||
/// </summary>
|
||||
public interface ITimingScope : IDisposable
|
||||
{
|
||||
void SetSuccess(bool success);
|
||||
}
|
||||
|
||||
private class TimingScope : ITimingScope
|
||||
{
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly string _operationName;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private bool _disposed;
|
||||
private bool _success = true;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for a specific operation
|
||||
/// </summary>
|
||||
public class OperationMetrics
|
||||
{
|
||||
private readonly List<double> _durations = new();
|
||||
private readonly object _lock = new();
|
||||
private double _maxMilliseconds;
|
||||
private double _minMilliseconds = double.MaxValue;
|
||||
private long _successCount;
|
||||
private long _totalCount;
|
||||
private double _totalMilliseconds;
|
||||
|
||||
public void Record(TimeSpan duration, bool success)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
double ms = duration.TotalMilliseconds;
|
||||
_durations.Add(ms);
|
||||
_totalCount++;
|
||||
if (success)
|
||||
{
|
||||
_successCount++;
|
||||
}
|
||||
|
||||
_totalMilliseconds += ms;
|
||||
_minMilliseconds = Math.Min(_minMilliseconds, ms);
|
||||
_maxMilliseconds = Math.Max(_maxMilliseconds, ms);
|
||||
|
||||
// Keep only last 1000 samples for percentile calculation
|
||||
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();
|
||||
int p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1;
|
||||
|
||||
return new MetricsStatistics
|
||||
{
|
||||
TotalCount = _totalCount,
|
||||
SuccessCount = _successCount,
|
||||
SuccessRate = _successCount / (double)_totalCount,
|
||||
AverageMilliseconds = _totalMilliseconds / _totalCount,
|
||||
MinMilliseconds = _minMilliseconds == double.MaxValue ? 0 : _minMilliseconds,
|
||||
MaxMilliseconds = _maxMilliseconds,
|
||||
Percentile95Milliseconds = sortedDurations.Count > 0 ? sortedDurations[Math.Max(0, p95Index)] : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for an operation
|
||||
/// </summary>
|
||||
public class MetricsStatistics
|
||||
{
|
||||
public long TotalCount { get; set; }
|
||||
public long SuccessCount { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
public double AverageMilliseconds { get; set; }
|
||||
public double MinMilliseconds { get; set; }
|
||||
public double MaxMilliseconds { get; set; }
|
||||
public double Percentile95Milliseconds { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Polly;
|
||||
using Polly.Timeout;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides retry policies for resilient operations
|
||||
/// </summary>
|
||||
public static class RetryPolicies
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext(typeof(RetryPolicies));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a retry policy with exponential backoff for read operations
|
||||
/// </summary>
|
||||
public static IAsyncPolicy<T> CreateReadPolicy<T>()
|
||||
{
|
||||
return Policy<T>
|
||||
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
|
||||
.WaitAndRetryAsync(
|
||||
3,
|
||||
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1)),
|
||||
(outcome, timespan, retryCount, context) =>
|
||||
{
|
||||
Exception? exception = outcome.Exception;
|
||||
Logger.Warning(exception,
|
||||
"Read operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
|
||||
retryCount,
|
||||
timespan.TotalMilliseconds,
|
||||
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a retry policy with exponential backoff for write operations
|
||||
/// </summary>
|
||||
public static IAsyncPolicy CreateWritePolicy()
|
||||
{
|
||||
return Policy
|
||||
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
|
||||
.WaitAndRetryAsync(
|
||||
3,
|
||||
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
|
||||
(exception, timespan, retryCount, context) =>
|
||||
{
|
||||
Logger.Warning(exception,
|
||||
"Write operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
|
||||
retryCount,
|
||||
timespan.TotalMilliseconds,
|
||||
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a retry policy for connection operations with longer delays
|
||||
/// </summary>
|
||||
public static IAsyncPolicy CreateConnectionPolicy()
|
||||
{
|
||||
return Policy
|
||||
.Handle<Exception>()
|
||||
.WaitAndRetryAsync(
|
||||
5,
|
||||
retryAttempt =>
|
||||
{
|
||||
// 2s, 4s, 8s, 16s, 32s
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(32, Math.Pow(2, retryAttempt)));
|
||||
return delay;
|
||||
},
|
||||
(exception, timespan, retryCount, context) =>
|
||||
{
|
||||
Logger.Warning(exception,
|
||||
"Connection retry {RetryCount} after {DelayMs}ms",
|
||||
retryCount,
|
||||
timespan.TotalMilliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a circuit breaker policy for protecting against repeated failures
|
||||
/// </summary>
|
||||
public static IAsyncPolicy<T> CreateCircuitBreakerPolicy<T>()
|
||||
{
|
||||
return Policy<T>
|
||||
.Handle<Exception>()
|
||||
.CircuitBreakerAsync(
|
||||
5,
|
||||
TimeSpan.FromSeconds(30),
|
||||
(result, timespan) =>
|
||||
{
|
||||
Logger.Error(result.Exception,
|
||||
"Circuit breaker opened for {BreakDurationSeconds}s due to repeated failures",
|
||||
timespan.TotalSeconds);
|
||||
},
|
||||
() => { Logger.Information("Circuit breaker reset - resuming normal operations"); },
|
||||
() => { Logger.Information("Circuit breaker half-open - testing operation"); });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a combined policy with retry and circuit breaker
|
||||
/// </summary>
|
||||
public static IAsyncPolicy<T> CreateCombinedPolicy<T>()
|
||||
{
|
||||
IAsyncPolicy<T> retry = CreateReadPolicy<T>();
|
||||
IAsyncPolicy<T> circuitBreaker = CreateCircuitBreakerPolicy<T>();
|
||||
|
||||
// Wrap retry around circuit breaker
|
||||
// This means retry happens first, and if all retries fail, it counts toward the circuit breaker
|
||||
return Policy.WrapAsync(retry, circuitBreaker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a timeout policy for operations
|
||||
/// </summary>
|
||||
public static IAsyncPolicy CreateTimeoutPolicy(TimeSpan timeout)
|
||||
{
|
||||
return Policy
|
||||
.TimeoutAsync(
|
||||
timeout,
|
||||
TimeoutStrategy.Pessimistic,
|
||||
async (context, timespan, task) =>
|
||||
{
|
||||
Logger.Warning(
|
||||
"Operation timed out after {TimeoutMs}ms. Operation: {Operation}",
|
||||
timespan.TotalMilliseconds,
|
||||
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
|
||||
|
||||
if (task != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions from the timed-out task
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bulkhead policy to limit concurrent operations
|
||||
/// </summary>
|
||||
public static IAsyncPolicy CreateBulkheadPolicy(int maxParallelization, int maxQueuingActions = 100)
|
||||
{
|
||||
return Policy
|
||||
.BulkheadAsync(
|
||||
maxParallelization,
|
||||
maxQueuingActions,
|
||||
context =>
|
||||
{
|
||||
Logger.Warning(
|
||||
"Bulkhead rejected operation. Max parallelization: {MaxParallel}, Queue: {MaxQueue}",
|
||||
maxParallelization,
|
||||
maxQueuingActions);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for applying retry policies
|
||||
/// </summary>
|
||||
public static class RetryPolicyExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes an operation with retry policy
|
||||
/// </summary>
|
||||
public static async Task<T> ExecuteWithRetryAsync<T>(
|
||||
this IAsyncPolicy<T> policy,
|
||||
Func<Task<T>> operation,
|
||||
string operationName)
|
||||
{
|
||||
var context = new Context { ["Operation"] = operationName };
|
||||
return await policy.ExecuteAsync(async ctx => await operation(), context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an operation with retry policy (non-generic)
|
||||
/// </summary>
|
||||
public static async Task ExecuteWithRetryAsync(
|
||||
this IAsyncPolicy policy,
|
||||
Func<Task> operation,
|
||||
string operationName)
|
||||
{
|
||||
var context = new Context { ["Operation"] = operationName };
|
||||
await policy.ExecuteAsync(async ctx => await operation(), context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages client sessions for the gRPC service.
|
||||
/// Tracks active sessions with unique session IDs.
|
||||
/// </summary>
|
||||
public class SessionManager : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<SessionManager>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active sessions.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount => _sessions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new session for a client.
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client identifier.</param>
|
||||
/// <param name="apiKey">The API key used for authentication (optional).</param>
|
||||
/// <returns>The session ID for the new session.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
|
||||
public string CreateSession(string clientId, string apiKey = null)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SessionManager));
|
||||
}
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
var sessionInfo = new SessionInfo
|
||||
{
|
||||
SessionId = sessionId,
|
||||
ClientId = clientId ?? string.Empty,
|
||||
ApiKey = apiKey ?? string.Empty,
|
||||
ConnectedAt = DateTime.UtcNow,
|
||||
LastActivity = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_sessions[sessionId] = sessionInfo;
|
||||
|
||||
Logger.Information("Created session {SessionId} for client {ClientId}", sessionId, clientId);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a session ID and updates the last activity timestamp.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID to validate.</param>
|
||||
/// <returns>True if the session is valid; otherwise, false.</returns>
|
||||
public bool ValidateSession(string sessionId)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo))
|
||||
{
|
||||
sessionInfo.LastActivity = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the session information for a session ID.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>The session information, or null if not found.</returns>
|
||||
public SessionInfo GetSession(string sessionId)
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo);
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID to terminate.</param>
|
||||
/// <returns>True if the session was terminated; otherwise, false.</returns>
|
||||
public bool TerminateSession(string sessionId)
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_sessions.TryRemove(sessionId, out SessionInfo sessionInfo))
|
||||
{
|
||||
Logger.Information("Terminated session {SessionId} for client {ClientId}", sessionId, sessionInfo.ClientId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active sessions.
|
||||
/// </summary>
|
||||
/// <returns>A list of all active session information.</returns>
|
||||
public IReadOnlyList<SessionInfo> GetAllSessions()
|
||||
{
|
||||
return _sessions.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the session manager and clears all sessions.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
var count = _sessions.Count;
|
||||
_sessions.Clear();
|
||||
|
||||
Logger.Information("SessionManager disposed, cleared {Count} sessions", count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains information about a client session.
|
||||
/// </summary>
|
||||
public class SessionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique session identifier.
|
||||
/// </summary>
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client identifier.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API key used for this session.
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time when the session was created.
|
||||
/// </summary>
|
||||
public DateTime ConnectedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time of the last activity on this session.
|
||||
/// </summary>
|
||||
public DateTime LastActivity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connected time as UTC ticks for the gRPC response.
|
||||
/// </summary>
|
||||
public long ConnectedSinceUtcTicks => ConnectedAt.Ticks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for collecting and formatting status information from various LmxProxy components
|
||||
/// </summary>
|
||||
public class StatusReportService
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
|
||||
private readonly DetailedHealthCheckService? _detailedHealthCheckService;
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
private readonly PerformanceMetrics _performanceMetrics;
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the StatusReportService class
|
||||
/// </summary>
|
||||
public StatusReportService(
|
||||
IScadaClient scadaClient,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics performanceMetrics,
|
||||
HealthCheckService healthCheckService,
|
||||
DetailedHealthCheckService? detailedHealthCheckService = null)
|
||||
{
|
||||
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
|
||||
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
|
||||
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
|
||||
_healthCheckService = healthCheckService ?? throw new ArgumentNullException(nameof(healthCheckService));
|
||||
_detailedHealthCheckService = detailedHealthCheckService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a comprehensive status report as HTML
|
||||
/// </summary>
|
||||
public async Task<string> GenerateHtmlReportAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
StatusData statusData = await CollectStatusDataAsync();
|
||||
return GenerateHtmlFromStatusData(statusData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error generating HTML status report");
|
||||
return GenerateErrorHtml(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a comprehensive status report as JSON
|
||||
/// </summary>
|
||||
public async Task<string> GenerateJsonReportAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
StatusData statusData = await CollectStatusDataAsync();
|
||||
return JsonSerializer.Serialize(statusData, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error generating JSON status report");
|
||||
return JsonSerializer.Serialize(new { error = ex.Message }, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the service is healthy
|
||||
/// </summary>
|
||||
public async Task<bool> IsHealthyAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
|
||||
return healthResult.Status == HealthStatus.Healthy;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error checking health status");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects status data from all components
|
||||
/// </summary>
|
||||
private async Task<StatusData> CollectStatusDataAsync()
|
||||
{
|
||||
var statusData = new StatusData
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
ServiceName = "ZB.MOM.WW.LmxProxy.Host",
|
||||
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"
|
||||
};
|
||||
|
||||
// Collect connection status
|
||||
statusData.Connection = new ConnectionStatus
|
||||
{
|
||||
IsConnected = _scadaClient.IsConnected,
|
||||
State = _scadaClient.ConnectionState.ToString(),
|
||||
NodeName = "N/A", // Could be extracted from configuration if needed
|
||||
GalaxyName = "N/A" // Could be extracted from configuration if needed
|
||||
};
|
||||
|
||||
// Collect subscription statistics
|
||||
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
|
||||
statusData.Subscriptions = new SubscriptionStatus
|
||||
{
|
||||
TotalClients = subscriptionStats.TotalClients,
|
||||
TotalTags = subscriptionStats.TotalTags,
|
||||
ActiveSubscriptions = subscriptionStats.TotalTags // Assuming same for simplicity
|
||||
};
|
||||
|
||||
// Collect performance metrics
|
||||
Dictionary<string, MetricsStatistics> perfMetrics = _performanceMetrics.GetStatistics();
|
||||
statusData.Performance = new PerformanceStatus
|
||||
{
|
||||
TotalOperations = perfMetrics.Values.Sum(m => m.TotalCount),
|
||||
AverageSuccessRate = perfMetrics.Count > 0 ? perfMetrics.Values.Average(m => m.SuccessRate) : 1.0,
|
||||
Operations = perfMetrics.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => new OperationStatus
|
||||
{
|
||||
TotalCount = kvp.Value.TotalCount,
|
||||
SuccessRate = kvp.Value.SuccessRate,
|
||||
AverageMilliseconds = kvp.Value.AverageMilliseconds,
|
||||
MinMilliseconds = kvp.Value.MinMilliseconds,
|
||||
MaxMilliseconds = kvp.Value.MaxMilliseconds
|
||||
})
|
||||
};
|
||||
|
||||
// Collect health check results
|
||||
try
|
||||
{
|
||||
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
|
||||
statusData.Health = new HealthInfo
|
||||
{
|
||||
Status = healthResult.Status.ToString(),
|
||||
Description = healthResult.Description ?? "",
|
||||
Data = healthResult.Data?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "") ??
|
||||
new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Collect detailed health check if available
|
||||
if (_detailedHealthCheckService != null)
|
||||
{
|
||||
HealthCheckResult detailedHealthResult =
|
||||
await _detailedHealthCheckService.CheckHealthAsync(new HealthCheckContext());
|
||||
statusData.DetailedHealth = new HealthInfo
|
||||
{
|
||||
Status = detailedHealthResult.Status.ToString(),
|
||||
Description = detailedHealthResult.Description ?? "",
|
||||
Data = detailedHealthResult.Data?.ToDictionary(kvp => kvp.Key,
|
||||
kvp => kvp.Value?.ToString() ?? "") ?? new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error collecting health check data");
|
||||
statusData.Health = new HealthInfo
|
||||
{
|
||||
Status = "Error",
|
||||
Description = $"Health check failed: {ex.Message}",
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
return statusData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates HTML from status data
|
||||
/// </summary>
|
||||
private static string GenerateHtmlFromStatusData(StatusData statusData)
|
||||
{
|
||||
var html = new StringBuilder();
|
||||
|
||||
html.AppendLine("<!DOCTYPE html>");
|
||||
html.AppendLine("<html>");
|
||||
html.AppendLine("<head>");
|
||||
html.AppendLine(" <title>LmxProxy Status</title>");
|
||||
html.AppendLine(" <meta charset=\"utf-8\">");
|
||||
html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
|
||||
html.AppendLine(" <meta http-equiv=\"refresh\" content=\"30\">");
|
||||
html.AppendLine(" <style>");
|
||||
html.AppendLine(
|
||||
" body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }");
|
||||
html.AppendLine(
|
||||
" .container { max-width: 1200px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
|
||||
html.AppendLine(" .header { text-align: center; margin-bottom: 30px; }");
|
||||
html.AppendLine(
|
||||
" .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }");
|
||||
html.AppendLine(
|
||||
" .status-card { background: #f9f9f9; padding: 15px; border-radius: 6px; border-left: 4px solid #007acc; }");
|
||||
html.AppendLine(" .status-card h3 { margin-top: 0; color: #333; }");
|
||||
html.AppendLine(" .status-value { font-weight: bold; color: #007acc; }");
|
||||
html.AppendLine(" .status-healthy { color: #28a745; }");
|
||||
html.AppendLine(" .status-warning { color: #ffc107; }");
|
||||
html.AppendLine(" .status-error { color: #dc3545; }");
|
||||
html.AppendLine(" .status-connected { border-left-color: #28a745; }");
|
||||
html.AppendLine(" .status-disconnected { border-left-color: #dc3545; }");
|
||||
html.AppendLine(" table { width: 100%; border-collapse: collapse; margin-top: 10px; }");
|
||||
html.AppendLine(" th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }");
|
||||
html.AppendLine(" th { background-color: #f2f2f2; }");
|
||||
html.AppendLine(
|
||||
" .timestamp { text-align: center; margin-top: 20px; color: #666; font-size: 0.9em; }");
|
||||
html.AppendLine(" </style>");
|
||||
html.AppendLine("</head>");
|
||||
html.AppendLine("<body>");
|
||||
html.AppendLine(" <div class=\"container\">");
|
||||
|
||||
// Header
|
||||
html.AppendLine(" <div class=\"header\">");
|
||||
html.AppendLine(" <h1>LmxProxy Status Dashboard</h1>");
|
||||
html.AppendLine($" <p>Service: {statusData.ServiceName} | Version: {statusData.Version}</p>");
|
||||
html.AppendLine(" </div>");
|
||||
|
||||
html.AppendLine(" <div class=\"status-grid\">");
|
||||
|
||||
// Connection Status Card
|
||||
string connectionClass = statusData.Connection.IsConnected ? "status-connected" : "status-disconnected";
|
||||
string connectionStatusText = statusData.Connection.IsConnected ? "Connected" : "Disconnected";
|
||||
string connectionStatusClass = statusData.Connection.IsConnected ? "status-healthy" : "status-error";
|
||||
|
||||
html.AppendLine($" <div class=\"status-card {connectionClass}\">");
|
||||
html.AppendLine(" <h3>MxAccess Connection</h3>");
|
||||
html.AppendLine(
|
||||
$" <p>Status: <span class=\"status-value {connectionStatusClass}\">{connectionStatusText}</span></p>");
|
||||
html.AppendLine(
|
||||
$" <p>State: <span class=\"status-value\">{statusData.Connection.State}</span></p>");
|
||||
html.AppendLine(" </div>");
|
||||
|
||||
// Subscription Status Card
|
||||
html.AppendLine(" <div class=\"status-card\">");
|
||||
html.AppendLine(" <h3>Subscriptions</h3>");
|
||||
html.AppendLine(
|
||||
$" <p>Total Clients: <span class=\"status-value\">{statusData.Subscriptions.TotalClients}</span></p>");
|
||||
html.AppendLine(
|
||||
$" <p>Total Tags: <span class=\"status-value\">{statusData.Subscriptions.TotalTags}</span></p>");
|
||||
html.AppendLine(
|
||||
$" <p>Active Subscriptions: <span class=\"status-value\">{statusData.Subscriptions.ActiveSubscriptions}</span></p>");
|
||||
html.AppendLine(" </div>");
|
||||
|
||||
// Performance Status Card
|
||||
html.AppendLine(" <div class=\"status-card\">");
|
||||
html.AppendLine(" <h3>Performance</h3>");
|
||||
html.AppendLine(
|
||||
$" <p>Total Operations: <span class=\"status-value\">{statusData.Performance.TotalOperations:N0}</span></p>");
|
||||
html.AppendLine(
|
||||
$" <p>Success Rate: <span class=\"status-value\">{statusData.Performance.AverageSuccessRate:P2}</span></p>");
|
||||
html.AppendLine(" </div>");
|
||||
|
||||
// Health Status Card
|
||||
string healthStatusClass = statusData.Health.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"healthy" => "status-healthy",
|
||||
"degraded" => "status-warning",
|
||||
_ => "status-error"
|
||||
};
|
||||
|
||||
html.AppendLine(" <div class=\"status-card\">");
|
||||
html.AppendLine(" <h3>Health Status</h3>");
|
||||
html.AppendLine(
|
||||
$" <p>Status: <span class=\"status-value {healthStatusClass}\">{statusData.Health.Status}</span></p>");
|
||||
html.AppendLine(
|
||||
$" <p>Description: <span class=\"status-value\">{statusData.Health.Description}</span></p>");
|
||||
html.AppendLine(" </div>");
|
||||
|
||||
html.AppendLine(" </div>");
|
||||
|
||||
// Performance Metrics Table
|
||||
if (statusData.Performance.Operations.Any())
|
||||
{
|
||||
html.AppendLine(" <div class=\"status-card\" style=\"margin-top: 20px;\">");
|
||||
html.AppendLine(" <h3>Operation Performance Metrics</h3>");
|
||||
html.AppendLine(" <table>");
|
||||
html.AppendLine(" <tr>");
|
||||
html.AppendLine(" <th>Operation</th>");
|
||||
html.AppendLine(" <th>Count</th>");
|
||||
html.AppendLine(" <th>Success Rate</th>");
|
||||
html.AppendLine(" <th>Avg (ms)</th>");
|
||||
html.AppendLine(" <th>Min (ms)</th>");
|
||||
html.AppendLine(" <th>Max (ms)</th>");
|
||||
html.AppendLine(" </tr>");
|
||||
|
||||
foreach (KeyValuePair<string, OperationStatus> operation in statusData.Performance.Operations)
|
||||
{
|
||||
html.AppendLine(" <tr>");
|
||||
html.AppendLine($" <td>{operation.Key}</td>");
|
||||
html.AppendLine($" <td>{operation.Value.TotalCount:N0}</td>");
|
||||
html.AppendLine($" <td>{operation.Value.SuccessRate:P2}</td>");
|
||||
html.AppendLine($" <td>{operation.Value.AverageMilliseconds:F2}</td>");
|
||||
html.AppendLine($" <td>{operation.Value.MinMilliseconds:F2}</td>");
|
||||
html.AppendLine($" <td>{operation.Value.MaxMilliseconds:F2}</td>");
|
||||
html.AppendLine(" </tr>");
|
||||
}
|
||||
|
||||
html.AppendLine(" </table>");
|
||||
html.AppendLine(" </div>");
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
html.AppendLine(
|
||||
$" <div class=\"timestamp\">Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC</div>");
|
||||
|
||||
html.AppendLine(" </div>");
|
||||
html.AppendLine("</body>");
|
||||
html.AppendLine("</html>");
|
||||
|
||||
return html.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates error HTML when status collection fails
|
||||
/// </summary>
|
||||
private static string GenerateErrorHtml(Exception ex)
|
||||
{
|
||||
return $@"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>LmxProxy Status - Error</title>
|
||||
<meta charset=""utf-8"">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }}
|
||||
.container {{ max-width: 800px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
||||
.error {{ color: #dc3545; background-color: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""container"">
|
||||
<h1>LmxProxy Status Dashboard</h1>
|
||||
<div class=""error"">
|
||||
<h3>Error Loading Status</h3>
|
||||
<p>An error occurred while collecting status information:</p>
|
||||
<p><strong>{ex.Message}</strong></p>
|
||||
</div>
|
||||
<div style=""text-align: center; margin-top: 20px; color: #666; font-size: 0.9em;"">
|
||||
Last updated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data structure for holding complete status information
|
||||
/// </summary>
|
||||
public class StatusData
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string ServiceName { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public ConnectionStatus Connection { get; set; } = new();
|
||||
public SubscriptionStatus Subscriptions { get; set; } = new();
|
||||
public PerformanceStatus Performance { get; set; } = new();
|
||||
public HealthInfo Health { get; set; } = new();
|
||||
public HealthInfo? DetailedHealth { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection status information
|
||||
/// </summary>
|
||||
public class ConnectionStatus
|
||||
{
|
||||
public bool IsConnected { get; set; }
|
||||
public string State { get; set; } = "";
|
||||
public string NodeName { get; set; } = "";
|
||||
public string GalaxyName { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscription status information
|
||||
/// </summary>
|
||||
public class SubscriptionStatus
|
||||
{
|
||||
public int TotalClients { get; set; }
|
||||
public int TotalTags { get; set; }
|
||||
public int ActiveSubscriptions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performance status information
|
||||
/// </summary>
|
||||
public class PerformanceStatus
|
||||
{
|
||||
public long TotalOperations { get; set; }
|
||||
public double AverageSuccessRate { get; set; }
|
||||
public Dictionary<string, OperationStatus> Operations { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual operation status
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check status information
|
||||
/// </summary>
|
||||
public class HealthInfo
|
||||
{
|
||||
public string Status { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public Dictionary<string, string> Data { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
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.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP web server that serves status information for the LmxProxy service
|
||||
/// </summary>
|
||||
public class StatusWebServer : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
|
||||
|
||||
private readonly WebServerConfiguration _configuration;
|
||||
private readonly StatusReportService _statusReportService;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _disposed;
|
||||
private HttpListener? _httpListener;
|
||||
private Task? _listenerTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the StatusWebServer class
|
||||
/// </summary>
|
||||
/// <param name="configuration">Web server configuration</param>
|
||||
/// <param name="statusReportService">Service for collecting status information</param>
|
||||
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_statusReportService = statusReportService ?? throw new ArgumentNullException(nameof(statusReportService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the web server and releases resources
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
Stop();
|
||||
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_httpListener?.Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the HTTP web server
|
||||
/// </summary>
|
||||
/// <returns>True if started successfully, false otherwise</returns>
|
||||
public bool Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_configuration.Enabled)
|
||||
{
|
||||
Logger.Information("Status web server is disabled");
|
||||
return true;
|
||||
}
|
||||
|
||||
Logger.Information("Starting status web server on port {Port}", _configuration.Port);
|
||||
|
||||
_httpListener = new HttpListener();
|
||||
|
||||
// Configure the URL prefix
|
||||
string 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 successfully on {Prefix}", prefix);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to start status web server");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the HTTP web server
|
||||
/// </summary>
|
||||
/// <returns>True if stopped successfully, false otherwise</returns>
|
||||
public bool Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_configuration.Enabled || _httpListener == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Logger.Information("Stopping status web server");
|
||||
|
||||
_cancellationTokenSource?.Cancel();
|
||||
|
||||
if (_listenerTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_listenerTask.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error waiting for listener task to complete");
|
||||
}
|
||||
}
|
||||
|
||||
_httpListener?.Stop();
|
||||
_httpListener?.Close();
|
||||
|
||||
Logger.Information("Status web server stopped successfully");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error stopping status web server");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main request handling loop
|
||||
/// </summary>
|
||||
private async Task HandleRequestsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.Information("Status web server listener started");
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening)
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpListenerContext? context = await _httpListener.GetContextAsync();
|
||||
|
||||
// Handle request asynchronously without waiting
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleRequestAsync(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error handling HTTP request from {RemoteEndPoint}",
|
||||
context.Request.RemoteEndPoint);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Expected when stopping the listener
|
||||
break;
|
||||
}
|
||||
catch (HttpListenerException ex) when (ex.ErrorCode == 995) // ERROR_OPERATION_ABORTED
|
||||
{
|
||||
// Expected when stopping the listener
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error in request listener loop");
|
||||
|
||||
// Brief delay before continuing to avoid tight error loops
|
||||
try
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Information("Status web server listener stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a single HTTP request
|
||||
/// </summary>
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
HttpListenerRequest? request = context.Request;
|
||||
HttpListenerResponse response = context.Response;
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Debug("Handling {Method} request to {Url} from {RemoteEndPoint}",
|
||||
request.HttpMethod, request.Url?.AbsolutePath, request.RemoteEndPoint);
|
||||
|
||||
// Only allow GET requests
|
||||
if (request.HttpMethod != "GET")
|
||||
{
|
||||
response.StatusCode = 405; // Method Not Allowed
|
||||
response.StatusDescription = "Method Not Allowed";
|
||||
await WriteResponseAsync(response, "Only GET requests are supported", "text/plain");
|
||||
return;
|
||||
}
|
||||
|
||||
string path = request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/";
|
||||
|
||||
switch (path)
|
||||
{
|
||||
case "/":
|
||||
await HandleStatusPageAsync(response);
|
||||
break;
|
||||
|
||||
case "/api/status":
|
||||
await HandleStatusApiAsync(response);
|
||||
break;
|
||||
|
||||
case "/api/health":
|
||||
await HandleHealthApiAsync(response);
|
||||
break;
|
||||
|
||||
default:
|
||||
response.StatusCode = 404; // Not Found
|
||||
response.StatusDescription = "Not Found";
|
||||
await WriteResponseAsync(response, "Resource not found", "text/plain");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error handling HTTP request");
|
||||
|
||||
try
|
||||
{
|
||||
response.StatusCode = 500; // Internal Server Error
|
||||
response.StatusDescription = "Internal Server Error";
|
||||
await WriteResponseAsync(response, "Internal server error", "text/plain");
|
||||
}
|
||||
catch (Exception responseEx)
|
||||
{
|
||||
Logger.Error(responseEx, "Error writing error response");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
response.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error closing HTTP response");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the main status page (HTML)
|
||||
/// </summary>
|
||||
private async Task HandleStatusPageAsync(HttpListenerResponse response)
|
||||
{
|
||||
string statusHtml = await _statusReportService.GenerateHtmlReportAsync();
|
||||
await WriteResponseAsync(response, statusHtml, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the status API endpoint (JSON)
|
||||
/// </summary>
|
||||
private async Task HandleStatusApiAsync(HttpListenerResponse response)
|
||||
{
|
||||
string statusJson = await _statusReportService.GenerateJsonReportAsync();
|
||||
await WriteResponseAsync(response, statusJson, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the health API endpoint (simple text)
|
||||
/// </summary>
|
||||
private async Task HandleHealthApiAsync(HttpListenerResponse response)
|
||||
{
|
||||
bool isHealthy = await _statusReportService.IsHealthyAsync();
|
||||
string healthText = isHealthy ? "OK" : "UNHEALTHY";
|
||||
response.StatusCode = isHealthy ? 200 : 503; // Service Unavailable if unhealthy
|
||||
await WriteResponseAsync(response, healthText, "text/plain");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a response to the HTTP context
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(content);
|
||||
response.ContentLength64 = buffer.Length;
|
||||
|
||||
using (Stream? output = response.OutputStream)
|
||||
{
|
||||
await output.WriteAsync(buffer, 0, buffer.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages subscriptions for multiple gRPC clients, handling tag subscriptions, message delivery, and client
|
||||
/// statistics.
|
||||
/// </summary>
|
||||
public class SubscriptionManager : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<SubscriptionManager>();
|
||||
|
||||
// Configuration for channel buffering
|
||||
private readonly int _channelCapacity;
|
||||
private readonly BoundedChannelFullMode _channelFullMode;
|
||||
private readonly ConcurrentDictionary<string, ClientSubscription> _clientSubscriptions = new();
|
||||
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly ConcurrentDictionary<string, TagSubscription> _tagSubscriptions = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubscriptionManager" /> class.
|
||||
/// </summary>
|
||||
/// <param name="scadaClient">The SCADA client to use for subscriptions.</param>
|
||||
/// <param name="configuration">The subscription configuration.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown if <paramref name="scadaClient" /> or <paramref name="configuration" />
|
||||
/// is null.
|
||||
/// </exception>
|
||||
public SubscriptionManager(IScadaClient scadaClient, SubscriptionConfiguration configuration)
|
||||
{
|
||||
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
|
||||
SubscriptionConfiguration configuration1 =
|
||||
configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
_channelCapacity = configuration1.ChannelCapacity;
|
||||
_channelFullMode = ParseChannelFullMode(configuration1.ChannelFullMode);
|
||||
|
||||
// Subscribe to connection state changes
|
||||
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
Logger.Information("SubscriptionManager initialized with channel capacity: {Capacity}, full mode: {Mode}",
|
||||
_channelCapacity, _channelFullMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the <see cref="SubscriptionManager" />, unsubscribing all clients and cleaning up resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
Logger.Information("Disposing SubscriptionManager");
|
||||
|
||||
// Unsubscribe from connection state changes
|
||||
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
|
||||
// Unsubscribe all clients
|
||||
var clientIds = _clientSubscriptions.Keys.ToList();
|
||||
foreach (string? clientId in clientIds)
|
||||
{
|
||||
UnsubscribeClient(clientId);
|
||||
}
|
||||
|
||||
_clientSubscriptions.Clear();
|
||||
_tagSubscriptions.Clear();
|
||||
|
||||
// Dispose the lock
|
||||
_lock?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active client subscriptions.
|
||||
/// </summary>
|
||||
public virtual int GetActiveSubscriptionCount() => _clientSubscriptions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the channel full mode string to <see cref="BoundedChannelFullMode" />.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mode string.</param>
|
||||
/// <returns>The parsed <see cref="BoundedChannelFullMode" /> value.</returns>
|
||||
private static BoundedChannelFullMode ParseChannelFullMode(string mode)
|
||||
{
|
||||
return mode?.ToUpperInvariant() switch
|
||||
{
|
||||
"DROPOLDEST" => BoundedChannelFullMode.DropOldest,
|
||||
"DROPNEWEST" => BoundedChannelFullMode.DropNewest,
|
||||
"WAIT" => BoundedChannelFullMode.Wait,
|
||||
_ => BoundedChannelFullMode.DropOldest // Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new subscription for a client to a set of tag addresses.
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client identifier.</param>
|
||||
/// <param name="addresses">The tag addresses to subscribe to.</param>
|
||||
/// <param name="ct">Optional cancellation token.</param>
|
||||
/// <returns>A channel for receiving tag updates.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
|
||||
public async Task<Channel<(string address, Vtq vtq)>> SubscribeAsync(
|
||||
string clientId,
|
||||
IEnumerable<string> addresses,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SubscriptionManager));
|
||||
}
|
||||
|
||||
var addressList = addresses.ToList();
|
||||
Logger.Information("Client {ClientId} subscribing to {Count} tags", clientId, addressList.Count);
|
||||
|
||||
// Create a bounded channel for this client with buffering
|
||||
var channel = Channel.CreateBounded<(string address, Vtq vtq)>(new BoundedChannelOptions(_channelCapacity)
|
||||
{
|
||||
FullMode = _channelFullMode,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
AllowSynchronousContinuations = false
|
||||
});
|
||||
|
||||
Logger.Debug("Created bounded channel for client {ClientId} with capacity {Capacity}", clientId,
|
||||
_channelCapacity);
|
||||
|
||||
var clientSubscription = new ClientSubscription
|
||||
{
|
||||
ClientId = clientId,
|
||||
Channel = channel,
|
||||
Addresses = new HashSet<string>(addressList),
|
||||
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct)
|
||||
};
|
||||
|
||||
_clientSubscriptions[clientId] = clientSubscription;
|
||||
|
||||
// Subscribe to each tag
|
||||
foreach (string? address in addressList)
|
||||
{
|
||||
await SubscribeToTagAsync(address, clientId);
|
||||
}
|
||||
|
||||
// Handle client disconnection
|
||||
clientSubscription.CancellationTokenSource.Token.Register(() =>
|
||||
{
|
||||
Logger.Information("Client {ClientId} disconnected, cleaning up subscriptions", clientId);
|
||||
UnsubscribeClient(clientId);
|
||||
});
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes a client from all tags and cleans up resources.
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client identifier.</param>
|
||||
public void UnsubscribeClient(string clientId)
|
||||
{
|
||||
if (_clientSubscriptions.TryRemove(clientId, out ClientSubscription? clientSubscription))
|
||||
{
|
||||
Logger.Information(
|
||||
"Unsubscribing client {ClientId} from {Count} tags. Stats: Delivered={Delivered}, Dropped={Dropped}",
|
||||
clientId, clientSubscription.Addresses.Count,
|
||||
clientSubscription.DeliveredMessageCount, clientSubscription.DroppedMessageCount);
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (string? address in clientSubscription.Addresses)
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
|
||||
{
|
||||
tagSubscription.ClientIds.Remove(clientId);
|
||||
|
||||
// If no more clients are subscribed to this tag, unsubscribe from SCADA
|
||||
if (tagSubscription.ClientIds.Count == 0)
|
||||
{
|
||||
Logger.Information(
|
||||
"No more clients subscribed to {Address}, removing SCADA subscription", address);
|
||||
|
||||
_tagSubscriptions.TryRemove(address, out _);
|
||||
|
||||
// Dispose the SCADA subscription
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tagSubscription.ScadaSubscription != null)
|
||||
{
|
||||
await tagSubscription.ScadaSubscription.DisposeAsync();
|
||||
Logger.Debug("Successfully disposed SCADA subscription for {Address}",
|
||||
address);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error disposing SCADA subscription for {Address}", address);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Debug(
|
||||
"Client {ClientId} removed from {Address} subscription (remaining clients: {Count})",
|
||||
clientId, address, tagSubscription.ClientIds.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
// Complete the channel
|
||||
clientSubscription.Channel.Writer.TryComplete();
|
||||
clientSubscription.CancellationTokenSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes a client to a tag address, creating a new SCADA subscription if needed.
|
||||
/// </summary>
|
||||
/// <param name="address">The tag address.</param>
|
||||
/// <param name="clientId">The client identifier.</param>
|
||||
private async Task SubscribeToTagAsync(string address, string clientId)
|
||||
{
|
||||
bool needsSubscription;
|
||||
TagSubscription? tagSubscription;
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? existingSubscription))
|
||||
{
|
||||
// Tag is already subscribed, just add this client
|
||||
existingSubscription.ClientIds.Add(clientId);
|
||||
Logger.Debug(
|
||||
"Client {ClientId} added to existing subscription for {Address} (total clients: {Count})",
|
||||
clientId, address, existingSubscription.ClientIds.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tag subscription and reserve the spot
|
||||
tagSubscription = new TagSubscription
|
||||
{
|
||||
Address = address,
|
||||
ClientIds = new HashSet<string> { clientId }
|
||||
};
|
||||
_tagSubscriptions[address] = tagSubscription;
|
||||
needsSubscription = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
if (needsSubscription && tagSubscription != null)
|
||||
{
|
||||
// Subscribe to SCADA outside of lock to avoid blocking
|
||||
Logger.Debug("Creating new SCADA subscription for {Address}", address);
|
||||
|
||||
try
|
||||
{
|
||||
IAsyncDisposable scadaSubscription = await _scadaClient.SubscribeAsync(
|
||||
new[] { address },
|
||||
(addr, vtq) => OnTagValueChanged(addr, vtq),
|
||||
CancellationToken.None);
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
tagSubscription.ScadaSubscription = scadaSubscription;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
Logger.Information("Successfully subscribed to {Address} for client {ClientId}", address, clientId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to subscribe to {Address}", address);
|
||||
|
||||
// Remove the failed subscription
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_tagSubscriptions.TryRemove(address, out _);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles tag value changes and delivers updates to all subscribed clients.
|
||||
/// </summary>
|
||||
/// <param name="address">The tag address.</param>
|
||||
/// <param name="vtq">The value, timestamp, and quality.</param>
|
||||
private void OnTagValueChanged(string address, Vtq vtq)
|
||||
{
|
||||
Logger.Debug("Tag value changed: {Address} = {Vtq}", address, vtq);
|
||||
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (!_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
|
||||
{
|
||||
Logger.Warning("Received update for untracked tag {Address}", address);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send update to all subscribed clients
|
||||
// Use the existing collection directly without ToList() since we're in a read lock
|
||||
foreach (string? clientId in tagSubscription.ClientIds)
|
||||
{
|
||||
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!clientSubscription.Channel.Writer.TryWrite((address, vtq)))
|
||||
{
|
||||
// Channel is full - with DropOldest mode, this should rarely happen
|
||||
Logger.Warning(
|
||||
"Channel full for client {ClientId}, dropping message for {Address}. Consider increasing buffer size.",
|
||||
clientId, address);
|
||||
clientSubscription.DroppedMessageCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
clientSubscription.DeliveredMessageCount++;
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("closed"))
|
||||
{
|
||||
Logger.Debug("Channel closed for client {ClientId}, removing subscription", clientId);
|
||||
// Schedule cleanup of disconnected client
|
||||
Task.Run(() => UnsubscribeClient(clientId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error sending update to client {ClientId}", clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current subscription statistics for all clients and tags.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="SubscriptionStats" /> object containing statistics.</returns>
|
||||
public virtual SubscriptionStats GetSubscriptionStats()
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var tagClientCounts = _tagSubscriptions.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.ClientIds.Count);
|
||||
|
||||
var clientStats = _clientSubscriptions.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => new ClientStats
|
||||
{
|
||||
SubscribedTags = kvp.Value.Addresses.Count,
|
||||
DeliveredMessages = kvp.Value.DeliveredMessageCount,
|
||||
DroppedMessages = kvp.Value.DroppedMessageCount
|
||||
});
|
||||
|
||||
return new SubscriptionStats
|
||||
{
|
||||
TotalClients = _clientSubscriptions.Count,
|
||||
TotalTags = _tagSubscriptions.Count,
|
||||
TagClientCounts = tagClientCounts,
|
||||
ClientStats = clientStats
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles SCADA client connection state changes and notifies clients of disconnection.
|
||||
/// </summary>
|
||||
/// <param name="sender">The event sender.</param>
|
||||
/// <param name="e">The connection state change event arguments.</param>
|
||||
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
|
||||
{
|
||||
Logger.Information("Connection state changed from {Previous} to {Current}",
|
||||
e.PreviousState, e.CurrentState);
|
||||
|
||||
// If we're disconnected, notify all subscribed clients with bad quality
|
||||
if (e.CurrentState != ConnectionState.Connected)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await NotifyAllClientsOfDisconnection();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error notifying clients of disconnection");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies all clients of a SCADA disconnection by sending bad quality updates.
|
||||
/// </summary>
|
||||
private async Task NotifyAllClientsOfDisconnection()
|
||||
{
|
||||
Logger.Information("Notifying all clients of disconnection");
|
||||
|
||||
var badQualityVtq = new Vtq(null, DateTime.UtcNow, Quality.Bad);
|
||||
|
||||
// Get all unique addresses being subscribed to
|
||||
var allAddresses = _tagSubscriptions.Keys.ToList();
|
||||
|
||||
// Send bad quality update for each address to all subscribed clients
|
||||
foreach (string? address in allAddresses)
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
|
||||
{
|
||||
var clientIds = tagSubscription.ClientIds.ToList();
|
||||
|
||||
foreach (string? clientId in clientIds)
|
||||
{
|
||||
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
|
||||
{
|
||||
try
|
||||
{
|
||||
await clientSubscription.Channel.Writer.WriteAsync((address, badQualityVtq));
|
||||
Logger.Debug("Sent bad quality notification for {Address} to client {ClientId}",
|
||||
address, clientId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failed to send bad quality notification to client {ClientId}",
|
||||
clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a client's subscription, including channel, addresses, and statistics.
|
||||
/// </summary>
|
||||
private class ClientSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the client identifier.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the channel for delivering tag updates.
|
||||
/// </summary>
|
||||
public Channel<(string address, Vtq vtq)> Channel { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of addresses the client is subscribed to.
|
||||
/// </summary>
|
||||
public HashSet<string> Addresses { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cancellation token source for the client.
|
||||
/// </summary>
|
||||
public CancellationTokenSource CancellationTokenSource { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of delivered messages.
|
||||
/// </summary>
|
||||
public long DeliveredMessageCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of dropped messages.
|
||||
/// </summary>
|
||||
public long DroppedMessageCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tag subscription, including address, client IDs, and SCADA subscription handle.
|
||||
/// </summary>
|
||||
private class TagSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tag address.
|
||||
/// </summary>
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of client IDs subscribed to this tag.
|
||||
/// </summary>
|
||||
public HashSet<string> ClientIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SCADA subscription handle.
|
||||
/// </summary>
|
||||
public IAsyncDisposable ScadaSubscription { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user