Files
EonaCat.Connections/EonaCat.Connections/Helpers/NetworkMonitor.cs
T
2026-06-17 08:05:50 +02:00

653 lines
26 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Text;
namespace EonaCat.Connections.Helpers
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
public class NetworkMonitor : IDisposable
{
private readonly string _targetHost;
private readonly int _pingIntervalMs;
private readonly int _maxHistory;
private readonly ConcurrentQueue<NetworkSample> _samples = new ConcurrentQueue<NetworkSample>();
private CancellationTokenSource _cts;
private Task _monitorTask;
private volatile bool _isRunning;
private CancellationTokenSource _autoReportCts;
private Task _autoReportTask;
private volatile bool _autoReportRunning;
public event EventHandler<NetworkOutageEventArgs> OnOutageDetected;
public event EventHandler<NetworkOutageEventArgs> OnOutageRecovered;
public event EventHandler<NetworkSample> OnSampleRecorded;
public bool IsRunning => _isRunning;
public string TargetHost => _targetHost;
/// <summary>
/// Gets whether automatic HTML report generation is currently running.
/// </summary>
public bool IsAutoHtmlReportRunning => _autoReportRunning;
public NetworkMonitor(string targetHost = "127.0.0.1", int pingIntervalMs = 5000, int maxHistory = 1000)
{
_targetHost = targetHost;
_pingIntervalMs = Math.Max(1000, pingIntervalMs);
_maxHistory = Math.Max(10, maxHistory);
}
public void Start()
{
if (_isRunning)
{
return;
}
_cts?.Dispose();
_cts = new CancellationTokenSource();
_isRunning = true;
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token));
}
public void Stop()
{
_isRunning = false;
_cts?.Cancel();
try
{
_monitorTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
}
private async Task MonitorLoopAsync(CancellationToken token)
{
bool wasReachable = true;
DateTime? outageStartedAt = null;
while (!token.IsCancellationRequested)
{
var sample = await CollectSampleAsync().ConfigureAwait(false);
RecordSample(sample);
OnSampleRecorded?.Invoke(this, sample);
if (!sample.IsReachable && wasReachable)
{
outageStartedAt = sample.Timestamp;
OnOutageDetected?.Invoke(this, new NetworkOutageEventArgs
{
TargetHost = _targetHost,
OutageStartedAt = sample.Timestamp,
Sample = sample
});
}
else if (sample.IsReachable && !wasReachable && outageStartedAt.HasValue)
{
OnOutageRecovered?.Invoke(this, new NetworkOutageEventArgs
{
TargetHost = _targetHost,
OutageStartedAt = outageStartedAt.Value,
OutageEndedAt = sample.Timestamp,
OutageDuration = sample.Timestamp - outageStartedAt.Value,
Sample = sample
});
outageStartedAt = null;
}
wasReachable = sample.IsReachable;
try
{
await Task.Delay(_pingIntervalMs, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task<NetworkSample> CollectSampleAsync()
{
var sample = new NetworkSample
{
Timestamp = DateTime.UtcNow,
TargetHost = _targetHost
};
try
{
using (var ping = new Ping())
{
var sw = Stopwatch.StartNew();
var reply = await ping.SendPingAsync(_targetHost, 5000).ConfigureAwait(false);
sw.Stop();
sample.RoundTripTimeMs = reply.RoundtripTime;
sample.MeasuredLatencyMs = sw.ElapsedMilliseconds;
sample.IsReachable = reply.Status == IPStatus.Success;
sample.PingStatus = reply.Status;
}
}
catch (PingException ex)
{
sample.IsReachable = false;
sample.Error = ex.InnerException?.Message ?? ex.Message;
}
catch (Exception ex)
{
sample.IsReachable = false;
sample.Error = ex.Message;
}
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
long totalBytesReceived = 0;
long totalBytesSent = 0;
foreach (var ni in interfaces)
{
if (ni.OperationalStatus != OperationalStatus.Up)
{
continue;
}
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback)
{
continue;
}
var stats = ni.GetIPv4Statistics();
totalBytesReceived += stats.BytesReceived;
totalBytesSent += stats.BytesSent;
}
sample.SystemBytesReceived = totalBytesReceived;
sample.SystemBytesSent = totalBytesSent;
}
catch
{
// Network interface stats may not be available on all platforms
}
return sample;
}
private void RecordSample(NetworkSample sample)
{
_samples.Enqueue(sample);
while (_samples.Count > _maxHistory)
{
_samples.TryDequeue(out _);
}
}
public IReadOnlyList<NetworkSample> GetSamples()
{
return _samples.ToArray();
}
public IReadOnlyList<NetworkSample> GetRecentSamples(int count)
{
var all = _samples.ToArray();
int skip = Math.Max(0, all.Length - count);
var result = new NetworkSample[Math.Min(count, all.Length)];
Array.Copy(all, skip, result, 0, result.Length);
return result;
}
public NetworkHealthReport GetHealthReport()
{
var samples = _samples.ToArray();
var report = new NetworkHealthReport
{
TargetHost = _targetHost,
GeneratedAt = DateTime.UtcNow,
TotalSamples = samples.Length
};
if (samples.Length == 0)
{
return report;
}
var reachable = new List<NetworkSample>();
var unreachable = new List<NetworkSample>();
foreach (var s in samples)
{
if (s.IsReachable)
{
reachable.Add(s);
}
else
{
unreachable.Add(s);
}
}
report.SuccessfulPings = reachable.Count;
report.FailedPings = unreachable.Count;
report.UptimePercentage = samples.Length > 0
? (double)reachable.Count / samples.Length * 100.0
: 0;
if (reachable.Count > 0)
{
long totalRtt = 0;
long minRtt = long.MaxValue;
long maxRtt = long.MinValue;
foreach (var s in reachable)
{
totalRtt += s.RoundTripTimeMs;
if (s.RoundTripTimeMs < minRtt)
{
minRtt = s.RoundTripTimeMs;
}
if (s.RoundTripTimeMs > maxRtt)
{
maxRtt = s.RoundTripTimeMs;
}
}
report.AverageLatencyMs = (double)totalRtt / reachable.Count;
report.MinLatencyMs = minRtt;
report.MaxLatencyMs = maxRtt;
// Calculate jitter (standard deviation of latency)
double sumSquaredDiff = 0;
foreach (var s in reachable)
{
var diff = s.RoundTripTimeMs - report.AverageLatencyMs;
sumSquaredDiff += diff * diff;
}
report.JitterMs = Math.Sqrt(sumSquaredDiff / reachable.Count);
}
// Detect outage windows
bool inOutage = false;
DateTime outageStart = DateTime.MinValue;
foreach (var s in samples)
{
if (!s.IsReachable && !inOutage)
{
inOutage = true;
outageStart = s.Timestamp;
}
else if (s.IsReachable && inOutage)
{
inOutage = false;
report.OutageWindows.Add(new OutageWindow
{
Start = outageStart,
End = s.Timestamp,
Duration = s.Timestamp - outageStart
});
}
}
if (inOutage)
{
report.OutageWindows.Add(new OutageWindow
{
Start = outageStart,
End = DateTime.UtcNow,
Duration = DateTime.UtcNow - outageStart,
IsOngoing = true
});
}
// Bandwidth estimate between last two samples
if (samples.Length >= 2)
{
var prev = samples[samples.Length - 2];
var curr = samples[samples.Length - 1];
var elapsed = (curr.Timestamp - prev.Timestamp).TotalSeconds;
if (elapsed > 0 && prev.SystemBytesReceived > 0 && curr.SystemBytesReceived > 0)
{
report.CurrentReceiveBytesPerSecond = (curr.SystemBytesReceived - prev.SystemBytesReceived) / elapsed;
report.CurrentSendBytesPerSecond = (curr.SystemBytesSent - prev.SystemBytesSent) / elapsed;
}
}
report.IsStable = report.UptimePercentage >= 99.0
&& report.JitterMs < 50
&& report.OutageWindows.Count(o => o.IsOngoing) == 0;
return report;
}
public void Dispose()
{
Stop();
StopAutoHtmlReport();
_cts?.Dispose();
_autoReportCts?.Dispose();
}
/// <summary>
/// Starts periodic automatic generation of the network health HTML report.
/// </summary>
/// <param name="outputDirectory">Directory where the HTML file is written.</param>
/// <param name="intervalSeconds">Interval in seconds between report generations.</param>
/// <param name="fileName">The HTML file name.</param>
public void StartAutoHtmlReport(string outputDirectory, int intervalSeconds = 60, string fileName = "status-network.html")
{
if (_autoReportRunning)
{
return;
}
_autoReportCts?.Dispose();
_autoReportCts = new CancellationTokenSource();
_autoReportRunning = true;
var interval = TimeSpan.FromSeconds(Math.Max(5, intervalSeconds));
var token = _autoReportCts.Token;
_autoReportTask = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
var report = GetHealthReport();
var html = report.GenerateHtmlReport();
var filePath = Path.Combine(outputDirectory, fileName);
File.WriteAllText(filePath, html, Encoding.UTF8);
}
catch
{
// Swallow to keep the loop alive
}
try
{
await Task.Delay(interval, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}, token);
}
/// <summary>
/// Stops the automatic HTML report generation.
/// </summary>
public void StopAutoHtmlReport()
{
if (!_autoReportRunning)
{
return;
}
_autoReportRunning = false;
_autoReportCts?.Cancel();
try
{
_autoReportTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
}
}
public class NetworkSample
{
public DateTime Timestamp { get; set; }
public string TargetHost { get; set; }
public bool IsReachable { get; set; }
public long RoundTripTimeMs { get; set; }
public long MeasuredLatencyMs { get; set; }
public IPStatus PingStatus { get; set; }
public string Error { get; set; }
public long SystemBytesReceived { get; set; }
public long SystemBytesSent { get; set; }
}
public class NetworkOutageEventArgs : EventArgs
{
public string TargetHost { get; set; }
public DateTime OutageStartedAt { get; set; }
public DateTime? OutageEndedAt { get; set; }
public TimeSpan? OutageDuration { get; set; }
public NetworkSample Sample { get; set; }
}
public class OutageWindow
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
public TimeSpan Duration { get; set; }
public bool IsOngoing { get; set; }
}
public class NetworkHealthReport
{
public string TargetHost { get; set; }
public DateTime GeneratedAt { get; set; }
public int TotalSamples { get; set; }
public int SuccessfulPings { get; set; }
public int FailedPings { get; set; }
public double UptimePercentage { get; set; }
public double AverageLatencyMs { get; set; }
public long MinLatencyMs { get; set; }
public long MaxLatencyMs { get; set; }
public double JitterMs { get; set; }
public double CurrentReceiveBytesPerSecond { get; set; }
public double CurrentSendBytesPerSecond { get; set; }
public bool IsStable { get; set; }
public List<OutageWindow> OutageWindows { get; set; } = new List<OutageWindow>();
public string GetSummary()
{
var sb = new StringBuilder();
sb.AppendLine($"=== Network Health Report ===");
sb.AppendLine($"Target: {TargetHost}");
sb.AppendLine($"Generated: {GeneratedAt:O}");
sb.AppendLine($"Samples: {TotalSamples}");
sb.AppendLine($"Success: {SuccessfulPings} / Failed: {FailedPings}");
sb.AppendLine($"Uptime: {UptimePercentage:F2}%");
sb.AppendLine($"Latency (avg): {AverageLatencyMs:F1} ms");
sb.AppendLine($"Latency (min): {MinLatencyMs} ms / (max): {MaxLatencyMs} ms");
sb.AppendLine($"Jitter: {JitterMs:F1} ms");
sb.AppendLine($"Recv BW: {CurrentReceiveBytesPerSecond:F0} B/s");
sb.AppendLine($"Send BW: {CurrentSendBytesPerSecond:F0} B/s");
sb.AppendLine($"Stable: {(IsStable ? "Yes" : "No")}");
sb.AppendLine($"Outages: {OutageWindows.Count}");
foreach (var o in OutageWindows)
{
var status = o.IsOngoing ? " (ONGOING)" : string.Empty;
sb.AppendLine($" {o.Start:HH:mm:ss} - {o.End:HH:mm:ss} ({o.Duration.TotalSeconds:F0}s){status}");
}
return sb.ToString();
}
public string GenerateHtmlReport(string title = "Network Health Report")
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{HtmlEncode(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; color: #333; }");
sb.AppendLine("h1 { color: #1a1a2e; }");
sb.AppendLine("h2 { color: #16213e; margin-top: 30px; }");
sb.AppendLine(".container { max-width: 1200px; margin: 0 auto; }");
sb.AppendLine(".summary-cards { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }");
sb.AppendLine(".card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 180px; }");
sb.AppendLine(".card .label { font-size: 0.85em; color: #666; text-transform: uppercase; }");
sb.AppendLine(".card .value { font-size: 1.8em; font-weight: bold; margin-top: 4px; }");
sb.AppendLine(".card.ok .value { color: #27ae60; }");
sb.AppendLine(".card.warn .value { color: #f39c12; }");
sb.AppendLine(".card.error .value { color: #e74c3c; }");
sb.AppendLine(".status-banner { padding: 16px 24px; border-radius: 8px; margin-bottom: 24px; font-size: 1.2em; font-weight: bold; }");
sb.AppendLine(".status-stable { background: #d4edda; color: #155724; }");
sb.AppendLine(".status-unstable { background: #f8d7da; color: #721c24; }");
sb.AppendLine("table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine("th { background: #1a1a2e; color: #fff; padding: 12px 16px; text-align: left; font-size: 0.85em; text-transform: uppercase; }");
sb.AppendLine("td { padding: 10px 16px; border-bottom: 1px solid #eee; font-size: 0.9em; }");
sb.AppendLine("tr:hover td { background: #f0f4ff; }");
sb.AppendLine("tr.ongoing td { background: #fff3cd; }");
sb.AppendLine(".progress-bg { background: #eee; border-radius: 6px; overflow: hidden; height: 22px; }");
sb.AppendLine(".progress-bar { height: 100%; border-radius: 6px; text-align: center; color: #fff; font-size: 0.8em; line-height: 22px; }");
sb.AppendLine(".no-issues { text-align: center; padding: 60px 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine(".no-issues .icon { font-size: 3em; }");
sb.AppendLine(".no-issues p { color: #27ae60; font-size: 1.2em; }");
sb.AppendLine(".footer { margin-top: 30px; text-align: center; color: #999; font-size: 0.85em; }");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"container\">");
sb.AppendLine($"<h1>{HtmlEncode(title)}</h1>");
// Status banner
if (TotalSamples > 0)
{
var bannerClass = IsStable ? "status-stable" : "status-unstable";
var bannerText = IsStable ? "&#10004; Network is Stable" : "&#9888; Network is Unstable";
sb.AppendLine($"<div class=\"status-banner {bannerClass}\">{bannerText}</div>");
}
// Summary cards
var uptimeClass = UptimePercentage >= 99 ? "ok" : UptimePercentage >= 95 ? "warn" : "error";
var latencyClass = AverageLatencyMs < 50 ? "ok" : AverageLatencyMs < 150 ? "warn" : "error";
var jitterClass = JitterMs < 20 ? "ok" : JitterMs < 50 ? "warn" : "error";
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Target</div><div class=\"value\" style=\"font-size:1em\">{HtmlEncode(TargetHost)}</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Samples</div><div class=\"value\">{TotalSamples}</div></div>");
sb.AppendLine($"<div class=\"card {uptimeClass}\"><div class=\"label\">Uptime</div><div class=\"value\">{UptimePercentage:F1}%</div></div>");
sb.AppendLine($"<div class=\"card {latencyClass}\"><div class=\"label\">Avg Latency</div><div class=\"value\">{AverageLatencyMs:F1} ms</div></div>");
sb.AppendLine($"<div class=\"card {jitterClass}\"><div class=\"label\">Jitter</div><div class=\"value\">{JitterMs:F1} ms</div></div>");
sb.AppendLine($"<div class=\"card {(FailedPings > 0 ? "error" : "ok")}\"><div class=\"label\">Failed Pings</div><div class=\"value\">{FailedPings}</div></div>");
sb.AppendLine("</div>");
// Uptime progress bar
sb.AppendLine("<h2>Uptime</h2>");
var barColor = UptimePercentage >= 99 ? "#27ae60" : UptimePercentage >= 95 ? "#f39c12" : "#e74c3c";
var barWidth = Math.Max(0, Math.Min(100, UptimePercentage));
sb.AppendLine($"<div class=\"progress-bg\"><div class=\"progress-bar\" style=\"width:{barWidth:F1}%;background:{barColor}\">{UptimePercentage:F2}%</div></div>");
// Latency / Bandwidth
sb.AppendLine("<h2>Performance</h2>");
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Min Latency</div><div class=\"value\">{MinLatencyMs} ms</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Max Latency</div><div class=\"value\">{MaxLatencyMs} ms</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Recv Bandwidth</div><div class=\"value\" style=\"font-size:1em\">{FormatBytes(CurrentReceiveBytesPerSecond)}/s</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Send Bandwidth</div><div class=\"value\" style=\"font-size:1em\">{FormatBytes(CurrentSendBytesPerSecond)}/s</div></div>");
sb.AppendLine("</div>");
// Outage windows
sb.AppendLine("<h2>Outage History</h2>");
if (OutageWindows.Count == 0)
{
sb.AppendLine("<div class=\"no-issues\">");
sb.AppendLine("<div class=\"icon\">&#10004;</div>");
sb.AppendLine("<p>No outages detected.</p>");
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<table>");
sb.AppendLine("<thead><tr><th>#</th><th>Start (UTC)</th><th>End (UTC)</th><th>Duration</th><th>Status</th></tr></thead>");
sb.AppendLine("<tbody>");
for (int i = 0; i < OutageWindows.Count; i++)
{
var o = OutageWindows[i];
var rowClass = o.IsOngoing ? "ongoing" : "";
var statusText = o.IsOngoing
? "<span style=\"color:#e74c3c;font-weight:bold\">ONGOING</span>"
: "<span style=\"color:#27ae60\">Recovered</span>";
sb.AppendLine($"<tr class=\"{rowClass}\">");
sb.AppendLine($"<td>{i + 1}</td>");
sb.AppendLine($"<td>{o.Start:yyyy-MM-dd HH:mm:ss}</td>");
sb.AppendLine($"<td>{(o.IsOngoing ? "-" : o.End.ToString("yyyy-MM-dd HH:mm:ss"))}</td>");
sb.AppendLine($"<td>{o.Duration.TotalSeconds:F0}s</td>");
sb.AppendLine($"<td>{statusText}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
}
sb.AppendLine($"<div class=\"footer\">Generated at {GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC &mdash; EonaCat.Connections</div>");
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
public void SaveHtmlReport(string filePath, string title = "Network Health Report")
{
var html = GenerateHtmlReport(title);
File.WriteAllText(filePath, html, Encoding.UTF8);
}
private static string FormatBytes(double bytes)
{
if (bytes >= 1_073_741_824)
{
return $"{bytes / 1_073_741_824:F2} GB";
}
if (bytes >= 1_048_576)
{
return $"{bytes / 1_048_576:F2} MB";
}
if (bytes >= 1024)
{
return $"{bytes / 1024:F2} KB";
}
return $"{bytes:F0} B";
}
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
}