added Network Performance infos

This commit is contained in:
Oliver Kieselbach
2026-03-04 22:55:23 +01:00
parent 074f5ff600
commit 22b6816110
6 changed files with 432 additions and 9 deletions
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Management;
using System.Net.NetworkInformation;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -53,6 +56,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
CollectBitLockerStatus();
CollectTpmStatus();
CollectAadJoinStatus();
CollectActiveNetworkInterfaceInfo();
return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage);
}
@@ -68,7 +72,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
// BitLocker can be enabled during enrollment via policy
CollectBitLockerStatus();
// Add more collectors here as needed
// Re-collect active network interface info (link speed, WiFi signal may have changed)
CollectActiveNetworkInterfaceInfo();
}
/// <summary>
@@ -662,6 +667,173 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return (skipUser, skipDevice);
}
/// <summary>
/// Collects active network interface details (name, type, link speed, MAC) using
/// System.Net.NetworkInformation — instant, no process spawn.
/// If the active interface is WiFi, fires off a separate async task to collect
/// WiFi signal info via netsh (fire-and-forget, never blocks).
/// </summary>
private void CollectActiveNetworkInterfaceInfo()
{
try
{
var activeNic = FindActiveNetworkInterface();
if (activeNic == null)
{
EmitDeviceInfoEvent("network_interface_info", "No active network interface found",
new Dictionary<string, object> { { "status", "no_active_interface" } });
return;
}
var data = new Dictionary<string, object>
{
{ "adapterName", activeNic.Name },
{ "adapterDescription", activeNic.Description },
{ "macAddress", activeNic.GetPhysicalAddress().ToString() },
{ "interfaceType", activeNic.NetworkInterfaceType.ToString() },
{ "linkSpeedMbps", activeNic.Speed / 1_000_000 },
{ "interfaceId", activeNic.Id }
};
var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
data["connectionType"] = isWifi ? "WiFi" : "Ethernet";
var gateways = activeNic.GetIPProperties().GatewayAddresses;
if (gateways.Count > 0)
{
var gwList = new List<string>();
foreach (var gw in gateways)
gwList.Add(gw.Address.ToString());
data["gateways"] = string.Join(", ", gwList);
}
var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)";
EmitDeviceInfoEvent("network_interface_info", message, data);
// Fire-and-forget WiFi signal collection — separate event, never blocks
if (isWifi)
{
Task.Run(() => CollectWiFiSignalInfo());
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}");
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
/// <summary>
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
/// never blocks other collection. VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo()
{
try
{
var psi = new ProcessStartInfo
{
FileName = "netsh.exe",
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
}
if (string.IsNullOrEmpty(output))
return;
var data = new Dictionary<string, object>();
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
data["wifiSsid"] = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var signal))
data["wifiSignalPercent"] = signal;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
data["wifiRadioType"] = value;
}
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value, out var channel))
data["wifiChannel"] = channel;
}
}
if (data.Count > 0)
{
var message = data.ContainsKey("wifiSsid")
? $"WiFi: {data["wifiSsid"]}"
: "WiFi signal info";
if (data.ContainsKey("wifiSignalPercent"))
message += $", Signal: {data["wifiSignalPercent"]}%";
if (data.ContainsKey("wifiRadioType"))
message += $" ({data["wifiRadioType"]})";
EmitDeviceInfoEvent("wifi_signal_info", message, data);
}
}
catch (Exception ex)
{
_logger.Debug($"WiFi signal info collection failed: {ex.Message}");
}
}
private void EmitDeviceInfoEvent(string eventType, string message, Dictionary<string, object> data)
{
_emitEvent(new EnrollmentEvent
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Management;
using System.Net.NetworkInformation;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
@@ -25,6 +26,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private PerformanceCounter _cpuCounter;
private PerformanceCounter _diskQueueCounter;
// Network throughput tracking
private string _activeNicId;
private string _activeNicName;
private long _prevBytesSent;
private long _prevBytesReceived;
private DateTime _prevNetSampleTime;
private bool _networkInitialized;
public PerformanceCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, int intervalSeconds = 60)
{
@@ -52,6 +61,31 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Warning($"Failed to initialize performance counters: {ex.Message}");
}
// Initialize network throughput baseline
try
{
var activeNic = FindActiveNetworkInterface();
if (activeNic != null)
{
_activeNicId = activeNic.Id;
_activeNicName = activeNic.Description;
var stats = activeNic.GetIPStatistics();
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
_logger.Debug($"Network throughput tracking initialized on: {_activeNicName}");
}
else
{
_logger.Debug("No active network interface found for throughput tracking");
}
}
catch (Exception ex)
{
_logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}");
}
// Start with an initial delay, then poll on interval
_pollTimer = new Timer(
_ => CollectMetrics(),
@@ -129,6 +163,96 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Debug($"Disk space query failed: {ex.Message}");
}
// Network throughput (bytes sent/received delta + rate)
try
{
if (_networkInitialized)
{
// Re-find the NIC by cached ID
NetworkInterface currentNic = null;
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.Id == _activeNicId)
{
currentNic = nic;
break;
}
}
}
catch { /* swallow */ }
// If cached NIC is gone, try to find a new active one
if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up)
{
currentNic = FindActiveNetworkInterface();
if (currentNic != null)
{
_activeNicId = currentNic.Id;
_activeNicName = currentNic.Description;
var freshStats = currentNic.GetIPStatistics();
_prevBytesSent = freshStats.BytesSent;
_prevBytesReceived = freshStats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_logger.Debug($"Network throughput tracking switched to: {_activeNicName}");
}
else
{
_networkInitialized = false;
_logger.Debug("Active network interface lost, throughput tracking paused");
}
}
if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up)
{
var stats = currentNic.GetIPStatistics();
var now = DateTime.UtcNow;
var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds;
if (elapsedSeconds > 0)
{
var deltaSent = stats.BytesSent - _prevBytesSent;
var deltaReceived = stats.BytesReceived - _prevBytesReceived;
// Guard against counter reset (adapter reconnect)
if (deltaSent < 0) deltaSent = 0;
if (deltaReceived < 0) deltaReceived = 0;
data["net_adapter"] = _activeNicName;
data["net_bytes_sent_delta"] = deltaSent;
data["net_bytes_received_delta"] = deltaReceived;
data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1);
data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1);
}
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = now;
}
}
else
{
// Try to re-initialize (e.g., network came up after boot)
var activeNic = FindActiveNetworkInterface();
if (activeNic != null)
{
_activeNicId = activeNic.Id;
_activeNicName = activeNic.Description;
var stats = activeNic.GetIPStatistics();
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
_logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}");
}
}
}
catch (Exception ex)
{
_logger.Debug($"Network throughput collection failed: {ex.Message}");
}
if (data.Count > 0)
{
_onEventCollected(new EnrollmentEvent
@@ -142,7 +266,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
Phase = EnrollmentPhase.Unknown,
Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " +
$"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " +
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB",
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" +
(data.ContainsKey("net_receive_rate_kbps")
? $", Net: ↓{data["net_receive_rate_kbps"]} / ↑{data["net_send_rate_kbps"]} kbps"
: ""),
Data = data
});
}
@@ -153,6 +280,37 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
public void Dispose()
{
Stop();
@@ -1 +1 @@
214
219
@@ -1125,6 +1125,8 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
const networkAdapters = getEventData("network_adapters");
const dnsConfig = getEventData("dns_configuration");
const proxyConfig = getEventData("proxy_configuration");
const networkInterfaceInfo = getEventData("network_interface_info");
const wifiSignalInfo = getEventData("wifi_signal_info");
const autopilotProfile = normalizeAutopilotProfile(getEventData("autopilot_profile"));
const aadJoinStatus = getEventData("aad_join_status");
const imeVersion = getEventData("ime_agent_version");
@@ -1134,8 +1136,8 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
const deviceLocation = getEventData("device_location");
// Check if we have any device detail events at all
const hasData = agentStarted || bootTime || osInfo || networkAdapters || dnsConfig || proxyConfig || autopilotProfile ||
aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus || deviceLocation;
const hasData = agentStarted || bootTime || osInfo || networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo ||
autopilotProfile || aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus || deviceLocation;
if (!hasData) return null;
@@ -1192,7 +1194,7 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
)}
{/* Network */}
{(networkAdapters || dnsConfig || proxyConfig) && (
{(networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo) && (
<DetailSection title="Network">
{/* Network Adapters */}
{networkAdapters && networkAdapters.adapters && (
@@ -1247,6 +1249,36 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
})
)}
{/* Active Network Interface */}
{networkInterfaceInfo && networkInterfaceInfo.status !== "no_active_interface" && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-1">Active Interface</div>
<DetailRow label="Connection" value={networkInterfaceInfo.connectionType === "WiFi" ? "WiFi" : "Ethernet"} />
{networkInterfaceInfo.adapterDescription && <DetailRow label="Adapter" value={networkInterfaceInfo.adapterDescription} />}
{networkInterfaceInfo.linkSpeedMbps !== undefined && (
<DetailRow label="Link Speed" value={
networkInterfaceInfo.linkSpeedMbps >= 1000
? `${(networkInterfaceInfo.linkSpeedMbps / 1000).toFixed(1)} Gbps`
: `${networkInterfaceInfo.linkSpeedMbps} Mbps`
} />
)}
{networkInterfaceInfo.gateways && <DetailRow label="Gateway" value={networkInterfaceInfo.gateways} />}
</div>
)}
{/* WiFi Signal */}
{wifiSignalInfo && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-1">WiFi</div>
{wifiSignalInfo.wifiSsid && <DetailRow label="SSID" value={wifiSignalInfo.wifiSsid} />}
{wifiSignalInfo.wifiSignalPercent !== undefined && (
<DetailRow label="Signal" value={`${wifiSignalInfo.wifiSignalPercent}%`} />
)}
{wifiSignalInfo.wifiRadioType && <DetailRow label="Radio" value={wifiSignalInfo.wifiRadioType} />}
{wifiSignalInfo.wifiChannel !== undefined && <DetailRow label="Channel" value={`${wifiSignalInfo.wifiChannel}`} />}
</div>
)}
{/* Proxy Configuration */}
{proxyConfig && (
<div className="mt-3 pt-3 border-t border-gray-200">
@@ -237,8 +237,11 @@ export default function PerformanceChart({ events }: PerformanceChartProps) {
const memoryUsedPercent: number[] = [];
const diskQueueValues: number[] = [];
const diskFreeValues: number[] = [];
const netReceiveValues: number[] = [];
const netSendValues: number[] = [];
const timestamps: string[] = [];
const diskTimestamps: string[] = [];
const netTimestamps: string[] = [];
for (const evt of events) {
const d = evt.data;
@@ -262,6 +265,15 @@ export default function PerformanceChart({ events }: PerformanceChartProps) {
diskFreeValues.push(dfg);
diskTimestamps.push(new Date(evt.timestamp).toLocaleTimeString());
}
// Network throughput (kbps → Mbps for display)
const netRecv = parseFloat(d.net_receive_rate_kbps ?? d.netReceiveRateKbps ?? "");
const netSend = parseFloat(d.net_send_rate_kbps ?? d.netSendRateKbps ?? "");
if (!isNaN(netRecv) && !isNaN(netSend)) {
netReceiveValues.push(netRecv / 1000); // kbps → Mbps
netSendValues.push(netSend / 1000);
netTimestamps.push(new Date(evt.timestamp).toLocaleTimeString());
}
}
const last = events[events.length - 1]?.data;
@@ -273,11 +285,16 @@ export default function PerformanceChart({ events }: PerformanceChartProps) {
memoryUsedPercent,
diskQueueValues,
diskFreeValues,
netReceiveValues,
netSendValues,
diskTimestamps,
netTimestamps,
timestamps,
latestCpu: cpuValues[cpuValues.length - 1] ?? 0,
latestMemory: memoryUsedPercent[memoryUsedPercent.length - 1] ?? 0,
latestDiskQueue: diskQueueValues[diskQueueValues.length - 1] ?? 0,
latestNetReceive: netReceiveValues[netReceiveValues.length - 1] ?? 0,
latestNetSend: netSendValues[netSendValues.length - 1] ?? 0,
diskFreeGb,
diskTotalGb,
sampleCount: events.length,
@@ -298,8 +315,8 @@ export default function PerformanceChart({ events }: PerformanceChartProps) {
<span className="text-xs text-gray-400">({metrics.sampleCount} samples)</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* CPU - top left */}
<div className={`grid grid-cols-1 sm:grid-cols-2 ${metrics.netReceiveValues.length >= 2 ? "lg:grid-cols-3" : "lg:grid-cols-4"} gap-4`}>
{/* CPU */}
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-500 uppercase">CPU</span>
@@ -397,6 +414,50 @@ export default function PerformanceChart({ events }: PerformanceChartProps) {
<div className="text-xs text-gray-400 mt-2">No data</div>
)}
</div>
{/* Network Download */}
{metrics.netReceiveValues.length >= 2 && (
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-500 uppercase">Net ↓</span>
<span className="text-sm font-semibold text-gray-900">
{metrics.latestNetReceive >= 1
? `${metrics.latestNetReceive.toFixed(1)} Mbps`
: `${(metrics.latestNetReceive * 1000).toFixed(0)} kbps`}
</span>
</div>
<Sparkline
values={metrics.netReceiveValues}
maxValue={Math.max(1, ...metrics.netReceiveValues)}
color="#0891b2"
timestamps={metrics.netTimestamps}
unit=" Mbps"
formatValue={(v) => v >= 1 ? `${v.toFixed(1)} Mbps` : `${(v * 1000).toFixed(0)} kbps`}
/>
</div>
)}
{/* Network Upload */}
{metrics.netSendValues.length >= 2 && (
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-500 uppercase">Net ↑</span>
<span className="text-sm font-semibold text-gray-900">
{metrics.latestNetSend >= 1
? `${metrics.latestNetSend.toFixed(1)} Mbps`
: `${(metrics.latestNetSend * 1000).toFixed(0)} kbps`}
</span>
</div>
<Sparkline
values={metrics.netSendValues}
maxValue={Math.max(1, ...metrics.netSendValues)}
color="#7c3aed"
timestamps={metrics.netTimestamps}
unit=" Mbps"
formatValue={(v) => v >= 1 ? `${v.toFixed(1)} Mbps` : `${(v * 1000).toFixed(0)} kbps`}
/>
</div>
)}
</div>
</div>
);
File diff suppressed because one or more lines are too long