massive re-org and re-structuring

This commit is contained in:
Oliver Kieselbach
2026-03-06 03:02:19 +01:00
parent d532ffd7a4
commit c91fd1a43e
116 changed files with 12789 additions and 12199 deletions
@@ -0,0 +1,701 @@
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;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join),
/// ESP configuration, WiFi signal, and helper methods.
/// </summary>
public partial class DeviceInfoCollector
{
private void CollectNetworkAndDnsConfiguration()
{
try
{
var adapters = new List<Dictionary<string, object>>();
var dnsServers = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " +
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
{
foreach (ManagementObject obj in searcher.Get())
{
// Network adapter info
var adapter = new Dictionary<string, object>
{
{ "description", obj["Description"]?.ToString() },
{ "macAddress", obj["MACAddress"]?.ToString() },
{ "dhcpEnabled", obj["DHCPEnabled"]?.ToString() },
{ "dhcpServer", obj["DHCPServer"]?.ToString() }
};
var ipAddresses = obj["IPAddress"] as string[];
if (ipAddresses != null)
adapter["ipAddresses"] = string.Join(", ", ipAddresses);
var subnets = obj["IPSubnet"] as string[];
if (subnets != null)
adapter["subnets"] = string.Join(", ", subnets);
var gateways = obj["DefaultIPGateway"] as string[];
if (gateways != null)
adapter["gateways"] = string.Join(", ", gateways);
adapters.Add(adapter);
// DNS info (extracted from the same result set)
var servers = obj["DNSServerSearchOrder"] as string[];
if (servers != null && servers.Length > 0)
{
dnsServers.Add(new Dictionary<string, object>
{
{ "adapter", obj["Description"]?.ToString() },
{ "servers", string.Join(", ", servers) }
});
}
}
}
EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
new Dictionary<string, object>
{
{ "adapterCount", adapters.Count },
{ "adapters", adapters }
});
EmitDeviceInfoEvent("dns_configuration", "DNS server configuration",
new Dictionary<string, object>
{
{ "dnsEntries", dnsServers }
});
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}");
}
}
private void CollectProxyConfiguration()
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
{
if (key != null)
{
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
}
}
// Also check WinHTTP proxy
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
{
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
}
var proxyType = "Direct";
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
proxyType = "Proxy";
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
proxyType = "PAC";
data["proxyType"] = proxyType;
EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}");
}
}
/// <summary>
/// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status.
/// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join.
/// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
/// not the actual live enrollment state.
/// </summary>
private (string enrollmentType, bool isHybridJoin) CollectAutopilotProfile()
{
var detectedType = "v1";
var isHybridJoin = false;
try
{
var data = new Dictionary<string, object>();
// Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info)
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
{
if (key != null)
{
// The registry key contains individual values that together form the Autopilot profile
// Read all available values and add them to data dictionary
foreach (var valueName in key.GetValueNames())
{
var value = key.GetValue(valueName);
if (value != null)
{
// Store with original casing from registry
var valueAsString = value.ToString();
if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) ||
string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase))
{
valueAsString = TryFormatJson(valueAsString);
}
data[valueName] = valueAsString;
}
}
// Extract key values for enrollment type detection
var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
? data["CloudAssignedDeviceRegistration"]?.ToString()
: null;
var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
? data["CloudAssignedEspEnabled"]?.ToString()
: null;
// Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
if (deviceReg == "2" || espEnabled == "0")
detectedType = "v2";
else
detectedType = "v1";
// --- Interpret AutopilotMode (profile capability, not live state) ---
// 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
var autopilotModeStr = data.ContainsKey("AutopilotMode")
? data["AutopilotMode"]?.ToString()
: null;
if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
{
string modeLabel;
switch (autopilotMode)
{
case 0: modeLabel = "User Driven"; break;
case 1: modeLabel = "Self Deploying"; break;
case 2: modeLabel = "Pre-Provisioning (White Glove)"; break;
default: modeLabel = $"Unknown ({autopilotMode})"; break;
}
data["autopilotModeLabel"] = modeLabel;
_logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
}
// --- Interpret CloudAssignedDomainJoinMethod ---
// 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join
var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod")
? data["CloudAssignedDomainJoinMethod"]?.ToString()
: null;
if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod))
{
string joinLabel;
switch (domainJoinMethod)
{
case 0: joinLabel = "Entra Join"; break;
case 1: joinLabel = "Hybrid Azure AD Join"; break;
default: joinLabel = $"Unknown ({domainJoinMethod})"; break;
}
data["domainJoinMethodLabel"] = joinLabel;
// Hybrid Join is reliably indicated by DomainJoinMethod == 1
isHybridJoin = domainJoinMethod == 1;
}
data["isHybridJoin"] = isHybridJoin;
if (isHybridJoin)
{
_logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
}
_logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
}
else
{
_logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found");
}
}
// Include enrollment type in autopilot_profile event data
data["enrollmentType"] = detectedType;
EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
// Emit dedicated enrollment_type_detected event for easy filtering
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_type_detected",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = detectedType == "v2"
? "Enrollment type: Autopilot v2 (Windows Device Preparation)"
: "Enrollment type: Autopilot v1 (Classic ESP)",
Data = new Dictionary<string, object> { { "enrollmentType", detectedType } }
});
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
}
return (detectedType, isHybridJoin);
}
private void CollectSecureBootStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State"))
{
if (key != null)
{
var value = key.GetValue("UEFISecureBootEnabled");
data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1;
}
else
{
data["uefiSecureBootEnabled"] = false;
data["note"] = "SecureBoot registry key not found";
}
}
EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}");
}
}
private void CollectBitLockerStatus()
{
try
{
var data = new Dictionary<string, object>();
var volumes = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"),
new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume")))
{
foreach (ManagementObject obj in searcher.Get())
{
volumes.Add(new Dictionary<string, object>
{
{ "driveLetter", obj["DriveLetter"]?.ToString() },
{ "protectionStatus", obj["ProtectionStatus"]?.ToString() },
{ "conversionStatus", obj["ConversionStatus"]?.ToString() },
{ "encryptionMethod", obj["EncryptionMethod"]?.ToString() }
});
}
}
data["volumes"] = volumes;
data["systemDriveProtected"] = volumes.Any(v =>
v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1");
EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}");
}
}
private void CollectTpmStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
{
foreach (ManagementObject obj in searcher.Get())
{
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
var specVersion = obj["SpecVersion"]?.ToString();
if (!string.IsNullOrEmpty(specVersion))
{
var parts = specVersion.Split(',');
data["specVersion"] = parts[0].Trim();
}
}
}
if (data.Count > 0)
{
EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data);
}
else
{
data["available"] = false;
EmitDeviceInfoEvent("tpm_status", "TPM: not available", data);
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}");
}
}
private void CollectAadJoinStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"))
{
if (joinInfoKey != null)
{
var subKeyNames = joinInfoKey.GetSubKeyNames();
if (subKeyNames.Length > 0)
{
using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0]))
{
if (subKey != null)
{
data["tenantId"] = subKey.GetValue("TenantId")?.ToString();
data["userEmail"] = subKey.GetValue("UserEmail")?.ToString();
data["joinType"] = "Azure AD Joined";
data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint
}
}
}
else
{
data["joinType"] = "Not Joined";
}
}
else
{
data["joinType"] = "Not Joined";
}
}
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}");
}
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key.
/// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
/// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
/// We use defensive interpretation: non-null AND non-zero = skip.
/// </summary>
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
{
bool? skipUser = null;
bool? skipDevice = null;
string enrollmentGuid = null;
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
_logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
return (null, null);
}
// Find the MDM enrollment entry (EnrollmentType == 6)
foreach (var guid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
continue;
enrollmentGuid = guid;
// Read FirstSync subkey
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
{
if (firstSyncKey == null)
{
_logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
break;
}
var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
if (rawSkipUser != null)
skipUser = Convert.ToInt32(rawSkipUser) != 0;
var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
if (rawSkipDevice != null)
skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
}
break; // Found the MDM enrollment entry
}
}
}
var data = new Dictionary<string, object>
{
{ "source", "registry_firstsync" }
};
if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}");
}
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
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
}
private static string TryFormatJson(string input)
{
if (string.IsNullOrWhiteSpace(input))
return input ?? string.Empty;
try
{
using (var doc = JsonDocument.Parse(input))
{
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
{
WriteIndented = true
});
}
}
catch
{
return input;
}
}
}
}
@@ -18,7 +18,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status,
/// bitlocker_status, aad_join_status) via the provided emitEvent action.
/// </summary>
public class DeviceInfoCollector
public partial class DeviceInfoCollector
{
private readonly string _sessionId;
private readonly string _tenantId;
@@ -206,683 +206,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// consolidated to halve the WMI round-trips (each creates a new COM connection).
/// Emits two events (network_adapters, dns_configuration) for backward compatibility.
/// </summary>
private void CollectNetworkAndDnsConfiguration()
{
try
{
var adapters = new List<Dictionary<string, object>>();
var dnsServers = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " +
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
{
foreach (ManagementObject obj in searcher.Get())
{
// Network adapter info
var adapter = new Dictionary<string, object>
{
{ "description", obj["Description"]?.ToString() },
{ "macAddress", obj["MACAddress"]?.ToString() },
{ "dhcpEnabled", obj["DHCPEnabled"]?.ToString() },
{ "dhcpServer", obj["DHCPServer"]?.ToString() }
};
var ipAddresses = obj["IPAddress"] as string[];
if (ipAddresses != null)
adapter["ipAddresses"] = string.Join(", ", ipAddresses);
var subnets = obj["IPSubnet"] as string[];
if (subnets != null)
adapter["subnets"] = string.Join(", ", subnets);
var gateways = obj["DefaultIPGateway"] as string[];
if (gateways != null)
adapter["gateways"] = string.Join(", ", gateways);
adapters.Add(adapter);
// DNS info (extracted from the same result set)
var servers = obj["DNSServerSearchOrder"] as string[];
if (servers != null && servers.Length > 0)
{
dnsServers.Add(new Dictionary<string, object>
{
{ "adapter", obj["Description"]?.ToString() },
{ "servers", string.Join(", ", servers) }
});
}
}
}
EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
new Dictionary<string, object>
{
{ "adapterCount", adapters.Count },
{ "adapters", adapters }
});
EmitDeviceInfoEvent("dns_configuration", "DNS server configuration",
new Dictionary<string, object>
{
{ "dnsEntries", dnsServers }
});
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}");
}
}
private void CollectProxyConfiguration()
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
{
if (key != null)
{
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
}
}
// Also check WinHTTP proxy
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
{
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
}
var proxyType = "Direct";
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
proxyType = "Proxy";
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
proxyType = "PAC";
data["proxyType"] = proxyType;
EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}");
}
}
/// <summary>
/// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status.
/// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join.
/// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
/// not the actual live enrollment state.
/// </summary>
private (string enrollmentType, bool isHybridJoin) CollectAutopilotProfile()
{
var detectedType = "v1";
var isHybridJoin = false;
try
{
var data = new Dictionary<string, object>();
// Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info)
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
{
if (key != null)
{
// The registry key contains individual values that together form the Autopilot profile
// Read all available values and add them to data dictionary
foreach (var valueName in key.GetValueNames())
{
var value = key.GetValue(valueName);
if (value != null)
{
// Store with original casing from registry
var valueAsString = value.ToString();
if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) ||
string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase))
{
valueAsString = TryFormatJson(valueAsString);
}
data[valueName] = valueAsString;
}
}
// Extract key values for enrollment type detection
var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
? data["CloudAssignedDeviceRegistration"]?.ToString()
: null;
var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
? data["CloudAssignedEspEnabled"]?.ToString()
: null;
// Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
if (deviceReg == "2" || espEnabled == "0")
detectedType = "v2";
else
detectedType = "v1";
// --- Interpret AutopilotMode (profile capability, not live state) ---
// 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
var autopilotModeStr = data.ContainsKey("AutopilotMode")
? data["AutopilotMode"]?.ToString()
: null;
if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
{
string modeLabel;
switch (autopilotMode)
{
case 0: modeLabel = "User Driven"; break;
case 1: modeLabel = "Self Deploying"; break;
case 2: modeLabel = "Pre-Provisioning (White Glove)"; break;
default: modeLabel = $"Unknown ({autopilotMode})"; break;
}
data["autopilotModeLabel"] = modeLabel;
_logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
}
// --- Interpret CloudAssignedDomainJoinMethod ---
// 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join
var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod")
? data["CloudAssignedDomainJoinMethod"]?.ToString()
: null;
if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod))
{
string joinLabel;
switch (domainJoinMethod)
{
case 0: joinLabel = "Entra Join"; break;
case 1: joinLabel = "Hybrid Azure AD Join"; break;
default: joinLabel = $"Unknown ({domainJoinMethod})"; break;
}
data["domainJoinMethodLabel"] = joinLabel;
// Hybrid Join is reliably indicated by DomainJoinMethod == 1
isHybridJoin = domainJoinMethod == 1;
}
data["isHybridJoin"] = isHybridJoin;
if (isHybridJoin)
{
_logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
}
_logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
}
else
{
_logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found");
}
}
// Include enrollment type in autopilot_profile event data
data["enrollmentType"] = detectedType;
EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
// Emit dedicated enrollment_type_detected event for easy filtering
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_type_detected",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = detectedType == "v2"
? "Enrollment type: Autopilot v2 (Windows Device Preparation)"
: "Enrollment type: Autopilot v1 (Classic ESP)",
Data = new Dictionary<string, object> { { "enrollmentType", detectedType } }
});
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
}
return (detectedType, isHybridJoin);
}
private void CollectSecureBootStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State"))
{
if (key != null)
{
var value = key.GetValue("UEFISecureBootEnabled");
data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1;
}
else
{
data["uefiSecureBootEnabled"] = false;
data["note"] = "SecureBoot registry key not found";
}
}
EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}");
}
}
private void CollectBitLockerStatus()
{
try
{
var data = new Dictionary<string, object>();
var volumes = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"),
new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume")))
{
foreach (ManagementObject obj in searcher.Get())
{
volumes.Add(new Dictionary<string, object>
{
{ "driveLetter", obj["DriveLetter"]?.ToString() },
{ "protectionStatus", obj["ProtectionStatus"]?.ToString() },
{ "conversionStatus", obj["ConversionStatus"]?.ToString() },
{ "encryptionMethod", obj["EncryptionMethod"]?.ToString() }
});
}
}
data["volumes"] = volumes;
data["systemDriveProtected"] = volumes.Any(v =>
v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1");
EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}");
}
}
private void CollectTpmStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
{
foreach (ManagementObject obj in searcher.Get())
{
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
var specVersion = obj["SpecVersion"]?.ToString();
if (!string.IsNullOrEmpty(specVersion))
{
var parts = specVersion.Split(',');
data["specVersion"] = parts[0].Trim();
}
}
}
if (data.Count > 0)
{
EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data);
}
else
{
data["available"] = false;
EmitDeviceInfoEvent("tpm_status", "TPM: not available", data);
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}");
}
}
private void CollectAadJoinStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"))
{
if (joinInfoKey != null)
{
var subKeyNames = joinInfoKey.GetSubKeyNames();
if (subKeyNames.Length > 0)
{
using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0]))
{
if (subKey != null)
{
data["tenantId"] = subKey.GetValue("TenantId")?.ToString();
data["userEmail"] = subKey.GetValue("UserEmail")?.ToString();
data["joinType"] = "Azure AD Joined";
data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint
}
}
}
else
{
data["joinType"] = "Not Joined";
}
}
else
{
data["joinType"] = "Not Joined";
}
}
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}");
}
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key.
/// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
/// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
/// We use defensive interpretation: non-null AND non-zero = skip.
/// </summary>
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
{
bool? skipUser = null;
bool? skipDevice = null;
string enrollmentGuid = null;
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
_logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
return (null, null);
}
// Find the MDM enrollment entry (EnrollmentType == 6)
foreach (var guid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
continue;
enrollmentGuid = guid;
// Read FirstSync subkey
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
{
if (firstSyncKey == null)
{
_logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
break;
}
var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
if (rawSkipUser != null)
skipUser = Convert.ToInt32(rawSkipUser) != 0;
var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
if (rawSkipDevice != null)
skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
}
break; // Found the MDM enrollment entry
}
}
}
var data = new Dictionary<string, object>
{
{ "source", "registry_firstsync" }
};
if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}");
}
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
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
}
private static string TryFormatJson(string input)
{
if (string.IsNullOrWhiteSpace(input))
return input ?? string.Empty;
try
{
using (var doc = JsonDocument.Parse(input))
{
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
{
WriteIndented = true
});
}
}
catch
{
return input;
}
}
}
}
@@ -0,0 +1,554 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: Hello policy detection, User Device Registration event handling, and Hello timers.
/// </summary>
public partial class EspAndHelloTracker
{
private void CheckHelloPolicy()
{
try
{
var (isEnabled, source) = DetectHelloPolicy();
lock (_stateLock)
{
// If policy was not configured before but is now, update state and emit event
if (!_isPolicyConfigured && isEnabled.HasValue)
{
_isPolicyConfigured = true;
_isHelloPolicyEnabled = isEnabled.Value;
var status = isEnabled.Value ? "enabled" : "disabled";
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_policy_detected",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Windows Hello for Business policy detected: {status} (via {source})",
Data = new Dictionary<string, object>
{
{ "helloEnabled", isEnabled.Value },
{ "policySource", source }
}
});
_logger.Info($"WHfB policy detected: {status} (source: {source})");
// Stop periodic policy check - we found what we were looking for
if (_policyCheckTimer != null)
{
_policyCheckTimer.Dispose();
_policyCheckTimer = null;
_logger.Debug("Stopped periodic Hello policy check - policy has been detected");
}
}
else if (!_isPolicyConfigured && !isEnabled.HasValue)
{
// Policy still not found - this is normal during early enrollment (Debug level to keep UI clean)
_logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Error checking WHfB policy: {ex.Message}");
}
}
private (bool? isEnabled, string source) DetectHelloPolicy()
{
// 1. Check CSP/Intune-delivered policy (per-tenant paths)
// Policy can be device-scoped: {tenantId}\Device\Policies
// or user-scoped: {tenantId}\{userSid}\Policies
try
{
using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
{
if (baseCspKey != null)
{
foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
{
using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false))
{
if (tenantKey != null)
{
// Check all subkeys (Device or user SIDs like S-1-5-...)
foreach (var scopeSubKey in tenantKey.GetSubKeyNames())
{
using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false))
{
if (policiesKey != null)
{
var value = policiesKey.GetValue("UsePassportForWork");
if (value != null)
{
var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase)
? "device"
: "user";
return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)");
}
}
}
}
}
}
}
}
}
}
catch { }
// 2. Check GPO-delivered policy
try
{
using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
{
if (gpoKey != null)
{
var value = gpoKey.GetValue("Enabled");
if (value != null)
{
return (Convert.ToInt32(value) == 1, "GPO");
}
}
}
}
catch { }
return (null, null);
}
private void StartEventLogWatcher()
{
try
{
// Watch for specific WHfB-related event IDs
var query = new EventLogQuery(
EventLogChannel,
PathType.LogName,
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]"
);
_watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"Started watching: {EventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start Hello event log watcher", ex);
}
}
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedEventIds.Contains(eventId))
return;
ProcessHelloEvent(
eventId,
(record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(),
record.ProviderName ?? "",
isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing Hello event record", ex);
}
}
private void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
{
string eventType;
EventSeverity severity;
string message;
bool shouldTriggerHelloCompleted = false;
switch (eventId)
{
case EventId_ProvisioningWillLaunch: // 358
eventType = "hello_provisioning_willlaunch";
severity = EventSeverity.Info;
message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)";
_logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)");
break;
case EventId_NgcKeyRegistered: // 300
eventType = "hello_provisioning_completed";
severity = EventSeverity.Info;
message = "Windows Hello for Business provisioned successfully - NGC key registered";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED successfully");
break;
case EventId_NgcKeyRegistrationFailed: // 301
eventType = "hello_provisioning_failed";
severity = EventSeverity.Error;
message = "Windows Hello for Business provisioning failed - NGC key registration error";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED with failure");
break;
case EventId_ProvisioningWillNotLaunch: // 360
eventType = "hello_provisioning_willnotlaunch";
severity = EventSeverity.Warning;
message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)";
// DO NOT mark Hello as completed - event 360 is just a snapshot and can change
_logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
break;
case EventId_ProvisioningBlocked: // 362
eventType = "hello_provisioning_blocked";
severity = EventSeverity.Warning;
message = "Windows Hello for Business provisioning blocked";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED (blocked)");
break;
case EventId_PinStatus: // 376
eventType = "hello_pin_status";
severity = EventSeverity.Info;
message = "Windows Hello PIN status update";
break;
default:
return;
}
// We only need the event ID and provider for tracking purposes.
// The full description can contain sensitive data (e.g. server responses with key IDs, UPNs, tokens).
var data = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", providerName ?? "" },
{ "description", "truncated" },
{ "eventTime", timestamp.ToString("o") }
};
if (isBackfill)
{
data["backfill"] = true;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = timestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
if (shouldTriggerHelloCompleted)
{
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
}
}
private bool MarkHelloCompleted()
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello terminal event received but Hello is already marked completed");
return false;
}
_isHelloCompleted = true;
HelloOutcome = "completed";
StopHelloCompletionTimerLocked();
return true;
}
}
/// <summary>
/// Called when the Hello wait timer expires after ESP exit.
/// If Hello wizard hasn't started by now, we assume it's not configured or was skipped.
/// Mark Hello as completed so enrollment can proceed.
/// </summary>
private void OnHelloWaitTimeout(object state)
{
lock (_stateLock)
{
if (_helloWizardStarted)
{
// Hello wizard already started, this timeout is obsolete
_logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
return;
}
if (_isHelloCompleted)
{
// Hello already completed via events 300/301 - timeout is obsolete
_logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
return;
}
// Timeout expired without Hello wizard starting.
// If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet
// (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment
// complete — start the long HelloCompletion timer and keep waiting.
// If Hello policy is disabled/unknown, assume Hello is not configured and proceed.
if (_isHelloPolicyEnabled)
{
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_wait_timeout",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
{ "espExitDetected", _espExitDetected },
{ "helloPolicyEnabled", true },
{ "action", "extended_wait" }
}
});
// Arm the long completion timer so we don't wait forever
StartHelloCompletionTimerLocked();
return;
}
// Hello policy disabled or unknown — assume Hello is not configured
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
_logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped");
_isHelloCompleted = true;
HelloOutcome = "not_configured";
StopHelloCompletionTimerLocked();
// Emit event for tracking
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_wait_timeout",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
{ "espExitDetected", _espExitDetected },
{ "helloPolicyEnabled", false },
{ "action", "enrollment_complete" }
}
});
// Trigger HelloCompleted so enrollment can proceed
try
{
HelloCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("Error invoking HelloCompleted from timeout", ex);
}
}
}
/// <summary>
/// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits.
/// The timer waits for Hello wizard to start (event 62404) within the configured timeout.
/// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
/// </summary>
public void StartHelloWaitTimer()
{
lock (_stateLock)
{
// Don't start if Hello already started or completed
if (_helloWizardStarted)
{
_logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping");
return;
}
if (_isHelloCompleted)
{
_logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping");
return;
}
// Don't start if timer already running
if (_helloWaitTimer != null)
{
_logger.Debug("StartHelloWaitTimer called but timer already running - skipping");
return;
}
_logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start");
_helloWaitTimer = new System.Threading.Timer(
OnHelloWaitTimeout,
null,
TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
TimeSpan.FromMilliseconds(-1) // One-shot timer
);
}
}
private void StartHelloCompletionTimerLocked()
{
if (_isHelloCompleted)
return;
if (_helloCompletionTimer != null)
return;
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
_helloCompletionTimer = new System.Threading.Timer(
OnHelloCompletionTimeout,
null,
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
}
private void StopHelloCompletionTimerLocked()
{
if (_helloCompletionTimer == null)
return;
try
{
_helloCompletionTimer.Dispose();
}
catch { }
finally
{
_helloCompletionTimer = null;
}
}
private void OnHelloCompletionTimeout(object state)
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello completion timeout fired but Hello already completed - ignoring");
return;
}
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
_isHelloCompleted = true;
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
StopHelloCompletionTimerLocked();
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_completion_timeout",
Severity = EventSeverity.Warning,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
{ "helloWizardStarted", _helloWizardStarted }
}
});
try
{
HelloCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("Error invoking HelloCompleted from completion timeout", ex);
}
}
}
private void BackfillRecentTerminalHelloEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
EventLogChannel,
PathType.LogName,
$"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
DateTime latestTimestamp = DateTime.MinValue;
int? latestEventId = null;
string latestProvider = string.Empty;
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var eventId = record.Id;
if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked)
continue;
var timestamp = record.TimeCreated ?? DateTime.MinValue;
if (timestamp < latestTimestamp)
continue;
latestTimestamp = timestamp;
latestEventId = eventId;
latestProvider = record.ProviderName ?? "";
}
}
if (latestEventId.HasValue)
{
_logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}");
ProcessHelloEvent(latestEventId.Value, (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), latestProvider, isBackfill: true);
}
else
{
_logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Hello terminal event backfill failed: {ex.Message}");
}
}
}
}
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: Shell-Core event log handling — ESP exit detection, ESP failure detection,
/// WhiteGlove detection, Hello wizard start detection, and ESP backfill.
/// </summary>
public partial class EspAndHelloTracker
{
private void StartShellCoreEventLogWatcher()
{
try
{
// Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
"*[System[(EventID=62404 or EventID=62407)]]"
);
_shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
_shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten;
_shellCoreWatcher.Enabled = true;
_logger.Info($"Started watching: {ShellCoreEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start Shell-Core event log watcher", ex);
}
}
private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedShellCoreEventIds.Contains(eventId))
return;
var description = record.FormatDescription() ?? $"Event ID {eventId}";
string eventType;
EventSeverity severity = EventSeverity.Info;
string message;
bool triggerFinalizingSetup = false;
string finalizingSetupReason = null;
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
// Check if this is AADHello or NGC (Hello wizard started)
if (description.Contains("AADHello") || description.Contains("'NGC'"))
{
eventType = "hello_wizard_started";
message = "Windows Hello wizard started (CloudExperienceHost)";
triggerFinalizingSetup = true;
finalizingSetupReason = "hello_wizard_started";
lock (_stateLock)
{
_helloWizardStarted = true;
// Stop the hello wait timer if running (Hello wizard appeared within timeout)
if (_helloWaitTimer != null)
{
_helloWaitTimer.Dispose();
_helloWaitTimer = null;
_logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
}
StartHelloCompletionTimerLocked();
}
_logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
}
else
{
// Not Hello-related, ignore
return;
}
break;
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
case EventId_ShellCore_WebAppEvent: // 62407
// WhiteGlove check FIRST — its description also contains "Exiting"
// which would match the generic OOBE_ESP.*Exiting regex below.
if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase))
{
// Guard: event 62407 can fire multiple times; only process once
lock (_stateLock)
{
if (_whiteGloveDetected) return;
_whiteGloveDetected = true;
}
eventType = "whiteglove_complete";
message = "WhiteGlove (Pre-Provisioning) completed successfully";
// Do NOT set triggerFinalizingSetup — WhiteGlove terminates the
// pre-provisioning phase entirely, it does not transition to FinalizingSetup.
_logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
}
// Check if this is ESP exit event
// Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting
// Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description.
// Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above.
else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
{
var failureType = ExtractEspFailureType(description);
eventType = "esp_failure";
severity = EventSeverity.Error;
message = $"ESP (Enrollment Status Page) reported a failure: {failureType}";
_logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}");
_detectedEspFailureType = failureType;
}
else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
eventType = "esp_exiting";
message = "ESP (Enrollment Status Page) phase exiting";
triggerFinalizingSetup = true;
finalizingSetupReason = "esp_exiting";
lock (_stateLock)
{
_espExitDetected = true;
// NOTE: We do NOT start the Hello wait timer here!
// Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
// EnrollmentTracker will decide based on _lastEspPhase whether to start the timer
}
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
}
else
{
// Not a tracked ESP event, ignore
return;
}
break;
default:
return;
}
var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
var eventData = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", record.ProviderName ?? "" },
{ "description", description },
{ "eventLogChannel", ShellCoreEventLogChannel },
{ "eventTime", eventTimestamp.ToString("o") }
};
if (eventType == "esp_failure" && _detectedEspFailureType != null)
{
eventData["failureType"] = _detectedEspFailureType;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = eventTimestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase
Message = message,
Data = eventData
});
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
// Trigger FinalizingSetup phase transition
if (triggerFinalizingSetup)
{
try
{
FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason);
}
catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
}
// Fire WhiteGloveCompleted if this was a WhiteGlove success event.
// Must happen AFTER the event has been emitted above so the
// whiteglove_complete event is in the spool before the agent exits.
if (eventType == "whiteglove_complete")
{
try
{
WhiteGloveCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
}
// Fire EspFailureDetected if this was an ESP failure event.
// Must happen AFTER the event has been emitted above so the
// esp_failure event is in the spool before the agent potentially shuts down.
if (eventType == "esp_failure" && _detectedEspFailureType != null)
{
var failureType = _detectedEspFailureType;
_detectedEspFailureType = null;
try
{
EspFailureDetected?.Invoke(this, failureType);
}
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
catch (Exception ex)
{
_logger.Error("Error processing Shell-Core event record", ex);
}
}
/// <summary>
/// Extracts a structured failure type from the Shell-Core event description.
/// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed".
/// </summary>
private static string ExtractEspFailureType(string description)
{
// Known failure type keywords to search for in order of specificity
string[] knownTypes = {
"ESPProgress_Failure",
"ESPProgress_Failed",
"ESPProgress_Timeout",
"ESPProgress_Abort",
"WhiteGlove_Failed",
"WhiteGlove_Failure"
};
foreach (var type in knownTypes)
{
if (description.Contains(type, StringComparison.OrdinalIgnoreCase))
return type;
}
return "Unknown_ESP_Failure";
}
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
// Check for ESP exit
if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
lock (_stateLock)
{
if (!_espExitDetected)
{
_espExitDetected = true;
_logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
}
}
}
// Check for ESP failures
if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
{
var failureType = ExtractEspFailureType(description);
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
try { EspFailureDetected?.Invoke(this, failureType); } catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
}
}
}
}
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove (Pre-Provisioning) complete
/// </summary>
public class EspAndHelloTracker : IDisposable
public partial class EspAndHelloTracker : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
@@ -259,878 +259,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
private void CheckHelloPolicy()
{
try
{
var (isEnabled, source) = DetectHelloPolicy();
lock (_stateLock)
{
// If policy was not configured before but is now, update state and emit event
if (!_isPolicyConfigured && isEnabled.HasValue)
{
_isPolicyConfigured = true;
_isHelloPolicyEnabled = isEnabled.Value;
var status = isEnabled.Value ? "enabled" : "disabled";
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_policy_detected",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Windows Hello for Business policy detected: {status} (via {source})",
Data = new Dictionary<string, object>
{
{ "helloEnabled", isEnabled.Value },
{ "policySource", source }
}
});
_logger.Info($"WHfB policy detected: {status} (source: {source})");
// Stop periodic policy check - we found what we were looking for
if (_policyCheckTimer != null)
{
_policyCheckTimer.Dispose();
_policyCheckTimer = null;
_logger.Debug("Stopped periodic Hello policy check - policy has been detected");
}
}
else if (!_isPolicyConfigured && !isEnabled.HasValue)
{
// Policy still not found - this is normal during early enrollment (Debug level to keep UI clean)
_logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Error checking WHfB policy: {ex.Message}");
}
}
private (bool? isEnabled, string source) DetectHelloPolicy()
{
// 1. Check CSP/Intune-delivered policy (per-tenant paths)
// Policy can be device-scoped: {tenantId}\Device\Policies
// or user-scoped: {tenantId}\{userSid}\Policies
try
{
using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
{
if (baseCspKey != null)
{
foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
{
using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false))
{
if (tenantKey != null)
{
// Check all subkeys (Device or user SIDs like S-1-5-...)
foreach (var scopeSubKey in tenantKey.GetSubKeyNames())
{
using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false))
{
if (policiesKey != null)
{
var value = policiesKey.GetValue("UsePassportForWork");
if (value != null)
{
var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase)
? "device"
: "user";
return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)");
}
}
}
}
}
}
}
}
}
}
catch { }
// 2. Check GPO-delivered policy
try
{
using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
{
if (gpoKey != null)
{
var value = gpoKey.GetValue("Enabled");
if (value != null)
{
return (Convert.ToInt32(value) == 1, "GPO");
}
}
}
}
catch { }
return (null, null);
}
private void StartEventLogWatcher()
{
try
{
// Watch for specific WHfB-related event IDs
var query = new EventLogQuery(
EventLogChannel,
PathType.LogName,
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]"
);
_watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"Started watching: {EventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start Hello event log watcher", ex);
}
}
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedEventIds.Contains(eventId))
return;
ProcessHelloEvent(
eventId,
(record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(),
record.ProviderName ?? "",
isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing Hello event record", ex);
}
}
private void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
{
string eventType;
EventSeverity severity;
string message;
bool shouldTriggerHelloCompleted = false;
switch (eventId)
{
case EventId_ProvisioningWillLaunch: // 358
eventType = "hello_provisioning_willlaunch";
severity = EventSeverity.Info;
message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)";
_logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)");
break;
case EventId_NgcKeyRegistered: // 300
eventType = "hello_provisioning_completed";
severity = EventSeverity.Info;
message = "Windows Hello for Business provisioned successfully - NGC key registered";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED successfully");
break;
case EventId_NgcKeyRegistrationFailed: // 301
eventType = "hello_provisioning_failed";
severity = EventSeverity.Error;
message = "Windows Hello for Business provisioning failed - NGC key registration error";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED with failure");
break;
case EventId_ProvisioningWillNotLaunch: // 360
eventType = "hello_provisioning_willnotlaunch";
severity = EventSeverity.Warning;
message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)";
// DO NOT mark Hello as completed - event 360 is just a snapshot and can change
_logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
break;
case EventId_ProvisioningBlocked: // 362
eventType = "hello_provisioning_blocked";
severity = EventSeverity.Warning;
message = "Windows Hello for Business provisioning blocked";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED (blocked)");
break;
case EventId_PinStatus: // 376
eventType = "hello_pin_status";
severity = EventSeverity.Info;
message = "Windows Hello PIN status update";
break;
default:
return;
}
// We only need the event ID and provider for tracking purposes.
// The full description can contain sensitive data (e.g. server responses with key IDs, UPNs, tokens).
var data = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", providerName ?? "" },
{ "description", "truncated" },
{ "eventTime", timestamp.ToString("o") }
};
if (isBackfill)
{
data["backfill"] = true;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = timestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
if (shouldTriggerHelloCompleted)
{
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
}
}
private bool MarkHelloCompleted()
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello terminal event received but Hello is already marked completed");
return false;
}
_isHelloCompleted = true;
HelloOutcome = "completed";
StopHelloCompletionTimerLocked();
return true;
}
}
private void StartShellCoreEventLogWatcher()
{
try
{
// Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
"*[System[(EventID=62404 or EventID=62407)]]"
);
_shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
_shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten;
_shellCoreWatcher.Enabled = true;
_logger.Info($"Started watching: {ShellCoreEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start Shell-Core event log watcher", ex);
}
}
private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedShellCoreEventIds.Contains(eventId))
return;
var description = record.FormatDescription() ?? $"Event ID {eventId}";
string eventType;
EventSeverity severity = EventSeverity.Info;
string message;
bool triggerFinalizingSetup = false;
string finalizingSetupReason = null;
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
// Check if this is AADHello or NGC (Hello wizard started)
if (description.Contains("AADHello") || description.Contains("'NGC'"))
{
eventType = "hello_wizard_started";
message = "Windows Hello wizard started (CloudExperienceHost)";
triggerFinalizingSetup = true;
finalizingSetupReason = "hello_wizard_started";
lock (_stateLock)
{
_helloWizardStarted = true;
// Stop the hello wait timer if running (Hello wizard appeared within timeout)
if (_helloWaitTimer != null)
{
_helloWaitTimer.Dispose();
_helloWaitTimer = null;
_logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
}
StartHelloCompletionTimerLocked();
}
_logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
}
else
{
// Not Hello-related, ignore
return;
}
break;
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
case EventId_ShellCore_WebAppEvent: // 62407
// WhiteGlove check FIRST — its description also contains "Exiting"
// which would match the generic OOBE_ESP.*Exiting regex below.
if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase))
{
// Guard: event 62407 can fire multiple times; only process once
lock (_stateLock)
{
if (_whiteGloveDetected) return;
_whiteGloveDetected = true;
}
eventType = "whiteglove_complete";
message = "WhiteGlove (Pre-Provisioning) completed successfully";
// Do NOT set triggerFinalizingSetup — WhiteGlove terminates the
// pre-provisioning phase entirely, it does not transition to FinalizingSetup.
_logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
}
// Check if this is ESP exit event
// Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting
// Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description.
// Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above.
else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
{
var failureType = ExtractEspFailureType(description);
eventType = "esp_failure";
severity = EventSeverity.Error;
message = $"ESP (Enrollment Status Page) reported a failure: {failureType}";
_logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}");
_detectedEspFailureType = failureType;
}
else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
eventType = "esp_exiting";
message = "ESP (Enrollment Status Page) phase exiting";
triggerFinalizingSetup = true;
finalizingSetupReason = "esp_exiting";
lock (_stateLock)
{
_espExitDetected = true;
// NOTE: We do NOT start the Hello wait timer here!
// Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
// EnrollmentTracker will decide based on _lastEspPhase whether to start the timer
}
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
}
else
{
// Not a tracked ESP event, ignore
return;
}
break;
default:
return;
}
var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
var eventData = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", record.ProviderName ?? "" },
{ "description", description },
{ "eventLogChannel", ShellCoreEventLogChannel },
{ "eventTime", eventTimestamp.ToString("o") }
};
if (eventType == "esp_failure" && _detectedEspFailureType != null)
{
eventData["failureType"] = _detectedEspFailureType;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = eventTimestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase
Message = message,
Data = eventData
});
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
// Trigger FinalizingSetup phase transition
if (triggerFinalizingSetup)
{
try
{
FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason);
}
catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
}
// Fire WhiteGloveCompleted if this was a WhiteGlove success event.
// Must happen AFTER the event has been emitted above so the
// whiteglove_complete event is in the spool before the agent exits.
if (eventType == "whiteglove_complete")
{
try
{
WhiteGloveCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
}
// Fire EspFailureDetected if this was an ESP failure event.
// Must happen AFTER the event has been emitted above so the
// esp_failure event is in the spool before the agent potentially shuts down.
if (eventType == "esp_failure" && _detectedEspFailureType != null)
{
var failureType = _detectedEspFailureType;
_detectedEspFailureType = null;
try
{
EspFailureDetected?.Invoke(this, failureType);
}
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
catch (Exception ex)
{
_logger.Error("Error processing Shell-Core event record", ex);
}
}
/// <summary>
/// Called when the Hello wait timer expires after ESP exit.
/// If Hello wizard hasn't started by now, we assume it's not configured or was skipped.
/// Mark Hello as completed so enrollment can proceed.
/// </summary>
private void OnHelloWaitTimeout(object state)
{
lock (_stateLock)
{
if (_helloWizardStarted)
{
// Hello wizard already started, this timeout is obsolete
_logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
return;
}
if (_isHelloCompleted)
{
// Hello already completed via events 300/301 - timeout is obsolete
_logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
return;
}
// Timeout expired without Hello wizard starting.
// If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet
// (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment
// complete — start the long HelloCompletion timer and keep waiting.
// If Hello policy is disabled/unknown, assume Hello is not configured and proceed.
if (_isHelloPolicyEnabled)
{
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_wait_timeout",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
{ "espExitDetected", _espExitDetected },
{ "helloPolicyEnabled", true },
{ "action", "extended_wait" }
}
});
// Arm the long completion timer so we don't wait forever
StartHelloCompletionTimerLocked();
return;
}
// Hello policy disabled or unknown — assume Hello is not configured
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
_logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped");
_isHelloCompleted = true;
HelloOutcome = "not_configured";
StopHelloCompletionTimerLocked();
// Emit event for tracking
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_wait_timeout",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
{ "espExitDetected", _espExitDetected },
{ "helloPolicyEnabled", false },
{ "action", "enrollment_complete" }
}
});
// Trigger HelloCompleted so enrollment can proceed
try
{
HelloCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("Error invoking HelloCompleted from timeout", ex);
}
}
}
/// <summary>
/// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits.
/// The timer waits for Hello wizard to start (event 62404) within the configured timeout.
/// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
/// </summary>
public void StartHelloWaitTimer()
{
lock (_stateLock)
{
// Don't start if Hello already started or completed
if (_helloWizardStarted)
{
_logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping");
return;
}
if (_isHelloCompleted)
{
_logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping");
return;
}
// Don't start if timer already running
if (_helloWaitTimer != null)
{
_logger.Debug("StartHelloWaitTimer called but timer already running - skipping");
return;
}
_logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start");
_helloWaitTimer = new System.Threading.Timer(
OnHelloWaitTimeout,
null,
TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
TimeSpan.FromMilliseconds(-1) // One-shot timer
);
}
}
private void StartHelloCompletionTimerLocked()
{
if (_isHelloCompleted)
return;
if (_helloCompletionTimer != null)
return;
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
_helloCompletionTimer = new System.Threading.Timer(
OnHelloCompletionTimeout,
null,
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
}
private void StopHelloCompletionTimerLocked()
{
if (_helloCompletionTimer == null)
return;
try
{
_helloCompletionTimer.Dispose();
}
catch { }
finally
{
_helloCompletionTimer = null;
}
}
private void OnHelloCompletionTimeout(object state)
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello completion timeout fired but Hello already completed - ignoring");
return;
}
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
_isHelloCompleted = true;
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
StopHelloCompletionTimerLocked();
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_completion_timeout",
Severity = EventSeverity.Warning,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
{ "helloWizardStarted", _helloWizardStarted }
}
});
try
{
HelloCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("Error invoking HelloCompleted from completion timeout", ex);
}
}
}
/// <summary>
/// Extracts a structured failure type from the Shell-Core event description.
/// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed".
/// </summary>
private static string ExtractEspFailureType(string description)
{
// Known failure type keywords to search for in order of specificity
string[] knownTypes = {
"ESPProgress_Failure",
"ESPProgress_Failed",
"ESPProgress_Timeout",
"ESPProgress_Abort",
"WhiteGlove_Failed",
"WhiteGlove_Failure"
};
foreach (var type in knownTypes)
{
if (description.Contains(type, StringComparison.OrdinalIgnoreCase))
return type;
}
return "Unknown_ESP_Failure";
}
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
// Check for ESP exit
if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
lock (_stateLock)
{
if (!_espExitDetected)
{
_espExitDetected = true;
_logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
}
}
}
// Check for ESP failures
if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
{
var failureType = ExtractEspFailureType(description);
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
try { EspFailureDetected?.Invoke(this, failureType); } catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
}
}
private void BackfillRecentTerminalHelloEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
EventLogChannel,
PathType.LogName,
$"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
DateTime latestTimestamp = DateTime.MinValue;
int? latestEventId = null;
string latestProvider = string.Empty;
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var eventId = record.Id;
if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked)
continue;
var timestamp = record.TimeCreated ?? DateTime.MinValue;
if (timestamp < latestTimestamp)
continue;
latestTimestamp = timestamp;
latestEventId = eventId;
latestProvider = record.ProviderName ?? "";
}
}
if (latestEventId.HasValue)
{
_logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}");
ProcessHelloEvent(latestEventId.Value, (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), latestProvider, isBackfill: true);
}
else
{
_logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Hello terminal event backfill failed: {ex.Message}");
}
}
public void Dispose()
{
Stop();
@@ -0,0 +1,716 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.IO;
using System.Linq;
using System.Management;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: Individual rule executor implementations (registry, WMI, command, file, eventlog, logparser).
/// </summary>
public partial class GatherRuleExecutor
{
private Dictionary<string, object> ExecuteRegistryRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var path = rule.Target;
if (string.IsNullOrEmpty(path))
return data;
// Determine hive
RegistryKey hive = null;
string subPath = path;
if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.LocalMachine;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.CurrentUser;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else
{
// Default to HKLM
hive = Registry.LocalMachine;
}
// Guard: only allow enrollment-relevant registry paths
if (!GatherRuleGuards.IsRegistryPathAllowed(subPath))
return EmitSecurityWarning(rule, "registry", path);
// Determine explicit valueName from parameters
string explicitValueName = null;
rule.Parameters?.TryGetValue("valueName", out explicitValueName);
try
{
using (var key = hive.OpenSubKey(subPath, false))
{
if (key == null)
{
// The key doesn't exist as a sub-key. If no explicit valueName was given,
// check whether the last path segment is a value name in the parent key.
// This handles the common case where the user specifies the full
// "HKLM\...\KeyName\ValueName" path directly in Target.
if (string.IsNullOrEmpty(explicitValueName))
{
var lastBackslash = subPath.LastIndexOf('\\');
if (lastBackslash > 0)
{
var parentSubPath = subPath.Substring(0, lastBackslash);
var inferredValueName = subPath.Substring(lastBackslash + 1);
using (var parentKey = hive.OpenSubKey(parentSubPath, false))
{
if (parentKey != null)
{
var valueNames = parentKey.GetValueNames();
if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
{
var value = parentKey.GetValue(inferredValueName);
data["exists"] = true;
data["path"] = path.Substring(0, path.LastIndexOf('\\'));
data[inferredValueName] = value?.ToString();
return data;
}
}
}
}
}
data["exists"] = false;
data["path"] = path;
return data;
}
data["exists"] = true;
data["path"] = path;
// Read specific value if specified in parameters
if (!string.IsNullOrEmpty(explicitValueName))
{
var value = key.GetValue(explicitValueName);
data[explicitValueName] = value?.ToString();
}
else
{
// Read all values
foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
{
try
{
var value = key.GetValue(name);
var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
data[displayName] = value?.ToString();
}
catch { }
}
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteWmiRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var query = rule.Target;
if (string.IsNullOrEmpty(query))
return data;
// Guard: only allow known-safe WMI classes
if (!GatherRuleGuards.IsWmiQueryAllowed(query))
return EmitSecurityWarning(rule, "wmi", query);
try
{
using (var searcher = new ManagementObjectSearcher(query))
{
int index = 0;
foreach (ManagementObject obj in searcher.Get())
{
var item = new Dictionary<string, object>();
foreach (var prop in obj.Properties)
{
try
{
item[prop.Name] = prop.Value?.ToString();
}
catch { }
}
if (item.Count > 0)
{
// For single result, flatten; for multiple, use indexed keys
if (index == 0)
{
foreach (var kvp in item)
data[kvp.Key] = kvp.Value;
}
else
{
data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
}
}
index++;
if (index >= 20) break; // Limit results
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteCommandRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var command = rule.Target;
if (string.IsNullOrEmpty(command))
return data;
// SECURITY: Check command against allowlist
if (!GatherRuleGuards.IsCommandAllowed(command))
{
_logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
data["blocked"] = true;
data["reason"] = "Command not on allowlist";
data["command"] = command;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
Data = data
});
return data;
}
try
{
var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
ProcessStartInfo psi;
if (isPowerShell)
{
// Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
else
{
psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c {command}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
using (var process = Process.Start(psi))
{
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(30000); // 30 second timeout
data["command"] = command;
data["exit_code"] = process.ExitCode;
if (!string.IsNullOrWhiteSpace(output))
{
data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
}
if (!string.IsNullOrWhiteSpace(error))
{
data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteFileRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
// Expand environment variables
filePath = Environment.ExpandEnvironmentVariables(filePath);
// Guard: only allow enrollment-relevant file paths
if (!GatherRuleGuards.IsFilePathAllowed(filePath))
return EmitSecurityWarning(rule, "file", filePath);
try
{
if (File.Exists(filePath))
{
var info = new FileInfo(filePath);
data["exists"] = true;
data["path"] = filePath;
data["size_bytes"] = info.Length;
data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
data["created"] = info.CreationTimeUtc.ToString("o");
// Read content if requested and file is small enough
string readContent;
if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
readContent == "true" && info.Length < 50000) // Max 50KB
{
// Read the last 4000 chars — most useful for log files where recent entries matter
const int maxChars = 4000;
string content;
bool truncated = false;
using (var reader = new StreamReader(filePath))
{
var full = reader.ReadToEnd();
if (full.Length > maxChars)
{
content = full.Substring(full.Length - maxChars);
truncated = true;
}
else
{
content = full;
}
}
data["content"] = truncated ? "(truncated) ..." + content : content;
}
}
else if (Directory.Exists(filePath))
{
data["exists"] = true;
data["is_directory"] = true;
data["path"] = filePath;
var files = Directory.GetFiles(filePath);
data["file_count"] = files.Length;
data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
}
else
{
data["exists"] = false;
data["path"] = filePath;
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteEventLogRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var logName = rule.Target;
if (string.IsNullOrEmpty(logName))
return data;
try
{
int maxEntries = 10;
if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
{
int.TryParse(maxEntriesStr, out maxEntries);
maxEntries = Math.Min(maxEntries, 50); // Cap at 50
}
// Build XPath query with optional filters
var conditions = new List<string>();
string sourceFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
&& !string.IsNullOrEmpty(sourceFilter))
{
conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
}
string eventIdFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
&& !string.IsNullOrEmpty(eventIdFilter))
{
conditions.Add($"EventID={eventIdFilter}");
}
string xpath = conditions.Count > 0
? $"*[System[{string.Join(" and ", conditions)}]]"
: "*";
string messageFilter = null;
rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
var query = new EventLogQuery(logName, PathType.LogName, xpath)
{
ReverseDirection = true // newest first
};
data["log_name"] = logName;
var entries = new List<string>();
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
{
using (record)
{
var message = "";
try { message = record.FormatDescription() ?? ""; }
catch { /* Some events lack formatting resources */ }
// Apply message filter (wildcard contains)
if (!string.IsNullOrEmpty(messageFilter))
{
var pattern = messageFilter.Trim('*');
if (string.IsNullOrEmpty(pattern) ||
message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
continue;
}
var level = "";
try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
catch { level = record.Level?.ToString() ?? "Unknown"; }
var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
}
}
}
data["entries"] = string.Join("\n", entries);
data["entries_returned"] = entries.Count;
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private static string EscapeXPath(string value)
{
return value.Replace("'", "&apos;");
}
private void ExecuteLogParserRule(GatherRule rule)
{
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return;
filePath = Environment.ExpandEnvironmentVariables(filePath);
// Apply IME log path override if set
if (!string.IsNullOrEmpty(_imeLogPathOverride))
{
var fileName = Path.GetFileName(filePath);
filePath = Path.Combine(_imeLogPathOverride, fileName);
}
// Get parameters
string patternStr;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
string.IsNullOrEmpty(patternStr))
{
_logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
return;
}
string trackPositionStr;
bool trackPosition = true;
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
bool.TryParse(trackPositionStr, out trackPosition);
string maxLinesStr;
int maxLines = 1000;
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
int.TryParse(maxLinesStr, out maxLines);
Regex pattern;
try
{
pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
_logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
return;
}
if (!File.Exists(filePath))
{
_logger.Debug($"LogParser rule {rule.RuleId}: file not found: {filePath}");
return;
}
try
{
var fileInfo = new FileInfo(filePath);
long startPosition = trackPosition
? _filePositionTracker.GetSafePosition(filePath, fileInfo.Length)
: 0;
// Nothing new to read
if (startPosition >= fileInfo.Length)
return;
int matchCount = 0;
int linesRead = 0;
long endPosition = startPosition;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPosition, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
string line;
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
{
linesRead++;
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
continue;
Match match;
try
{
match = pattern.Match(entry.Message);
}
catch (RegexMatchTimeoutException)
{
continue;
}
if (!match.Success)
continue;
// Build data dictionary from named capture groups
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue; // Skip full match group
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
// Add CMTrace metadata
data["logTimestamp"] = entry.Timestamp.ToString("o");
data["logComponent"] = entry.Component;
data["logType"] = entry.Type;
data["logMessage"] = TruncateMessage(entry.Message, 500);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = entry.Timestamp,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
}
// Capture the final stream position after reading
endPosition = stream.Position;
}
}
if (trackPosition)
_filePositionTracker.SetPosition(filePath, endPosition);
if (matchCount > 0)
_logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
}
catch (Exception ex)
{
_logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
}
}
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
{
// If the rule specifies a severity, use it
if (!string.IsNullOrEmpty(ruleOverride))
{
switch (ruleOverride.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
}
}
// Otherwise derive from CMTrace log type
switch (cmTraceType)
{
case 2: return EventSeverity.Warning;
case 3: return EventSeverity.Error;
default: return EventSeverity.Info;
}
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
/// <summary>
/// Emits a security_warning event and returns an empty result dictionary.
/// Called when a gather rule targets a path/query not on the allowlist.
/// </summary>
private Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
{
_logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
var data = new Dictionary<string, object>
{
["blocked"] = true,
["reason"] = $"{collectorType} target not on allowlist",
["target"] = target,
["ruleId"] = rule.RuleId,
};
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
Data = data
});
return new Dictionary<string, object>();
}
private EventSeverity ParseSeverity(string severity)
{
if (string.IsNullOrEmpty(severity))
return EventSeverity.Info;
switch (severity.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
default: return EventSeverity.Info;
}
}
private void StopAllTimers()
{
foreach (var timer in _intervalTimers.Values)
{
timer.Dispose();
}
_intervalTimers.Clear();
}
/// <summary>
/// Blocks until all startup rules that were queued by the most recent <see cref="UpdateRules"/>
/// call have finished executing, or the timeout elapses.
/// Returns true if all rules completed within the timeout; false if timed out.
/// </summary>
public bool WaitForStartupRules(int timeoutSeconds = 120)
{
var latch = _startupRulesLatch;
if (latch == null)
return true; // no startup rules were queued
_logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
_logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
return completed;
}
public void Dispose()
{
StopAllTimers();
_startupRulesLatch?.Dispose();
_startupRulesLatch = null;
}
}
}
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Executes gather rules received from the backend API
/// Supports registry, eventlog, wmi, file, command_allowlisted, and logparser collector types
/// </summary>
public class GatherRuleExecutor : IDisposable
public partial class GatherRuleExecutor : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
@@ -197,696 +197,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
private Dictionary<string, object> ExecuteRegistryRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var path = rule.Target;
if (string.IsNullOrEmpty(path))
return data;
// Determine hive
RegistryKey hive = null;
string subPath = path;
if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.LocalMachine;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.CurrentUser;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else
{
// Default to HKLM
hive = Registry.LocalMachine;
}
// Guard: only allow enrollment-relevant registry paths
if (!GatherRuleGuards.IsRegistryPathAllowed(subPath))
return EmitSecurityWarning(rule, "registry", path);
// Determine explicit valueName from parameters
string explicitValueName = null;
rule.Parameters?.TryGetValue("valueName", out explicitValueName);
try
{
using (var key = hive.OpenSubKey(subPath, false))
{
if (key == null)
{
// The key doesn't exist as a sub-key. If no explicit valueName was given,
// check whether the last path segment is a value name in the parent key.
// This handles the common case where the user specifies the full
// "HKLM\...\KeyName\ValueName" path directly in Target.
if (string.IsNullOrEmpty(explicitValueName))
{
var lastBackslash = subPath.LastIndexOf('\\');
if (lastBackslash > 0)
{
var parentSubPath = subPath.Substring(0, lastBackslash);
var inferredValueName = subPath.Substring(lastBackslash + 1);
using (var parentKey = hive.OpenSubKey(parentSubPath, false))
{
if (parentKey != null)
{
var valueNames = parentKey.GetValueNames();
if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
{
var value = parentKey.GetValue(inferredValueName);
data["exists"] = true;
data["path"] = path.Substring(0, path.LastIndexOf('\\'));
data[inferredValueName] = value?.ToString();
return data;
}
}
}
}
}
data["exists"] = false;
data["path"] = path;
return data;
}
data["exists"] = true;
data["path"] = path;
// Read specific value if specified in parameters
if (!string.IsNullOrEmpty(explicitValueName))
{
var value = key.GetValue(explicitValueName);
data[explicitValueName] = value?.ToString();
}
else
{
// Read all values
foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
{
try
{
var value = key.GetValue(name);
var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
data[displayName] = value?.ToString();
}
catch { }
}
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteWmiRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var query = rule.Target;
if (string.IsNullOrEmpty(query))
return data;
// Guard: only allow known-safe WMI classes
if (!GatherRuleGuards.IsWmiQueryAllowed(query))
return EmitSecurityWarning(rule, "wmi", query);
try
{
using (var searcher = new ManagementObjectSearcher(query))
{
int index = 0;
foreach (ManagementObject obj in searcher.Get())
{
var item = new Dictionary<string, object>();
foreach (var prop in obj.Properties)
{
try
{
item[prop.Name] = prop.Value?.ToString();
}
catch { }
}
if (item.Count > 0)
{
// For single result, flatten; for multiple, use indexed keys
if (index == 0)
{
foreach (var kvp in item)
data[kvp.Key] = kvp.Value;
}
else
{
data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
}
}
index++;
if (index >= 20) break; // Limit results
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteCommandRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var command = rule.Target;
if (string.IsNullOrEmpty(command))
return data;
// SECURITY: Check command against allowlist
if (!GatherRuleGuards.IsCommandAllowed(command))
{
_logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
data["blocked"] = true;
data["reason"] = "Command not on allowlist";
data["command"] = command;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
Data = data
});
return data;
}
try
{
var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
ProcessStartInfo psi;
if (isPowerShell)
{
// Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
else
{
psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c {command}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
using (var process = Process.Start(psi))
{
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(30000); // 30 second timeout
data["command"] = command;
data["exit_code"] = process.ExitCode;
if (!string.IsNullOrWhiteSpace(output))
{
data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
}
if (!string.IsNullOrWhiteSpace(error))
{
data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteFileRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
// Expand environment variables
filePath = Environment.ExpandEnvironmentVariables(filePath);
// Guard: only allow enrollment-relevant file paths
if (!GatherRuleGuards.IsFilePathAllowed(filePath))
return EmitSecurityWarning(rule, "file", filePath);
try
{
if (File.Exists(filePath))
{
var info = new FileInfo(filePath);
data["exists"] = true;
data["path"] = filePath;
data["size_bytes"] = info.Length;
data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
data["created"] = info.CreationTimeUtc.ToString("o");
// Read content if requested and file is small enough
string readContent;
if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
readContent == "true" && info.Length < 50000) // Max 50KB
{
// Read the last 4000 chars — most useful for log files where recent entries matter
const int maxChars = 4000;
string content;
bool truncated = false;
using (var reader = new StreamReader(filePath))
{
var full = reader.ReadToEnd();
if (full.Length > maxChars)
{
content = full.Substring(full.Length - maxChars);
truncated = true;
}
else
{
content = full;
}
}
data["content"] = truncated ? "(truncated) ..." + content : content;
}
}
else if (Directory.Exists(filePath))
{
data["exists"] = true;
data["is_directory"] = true;
data["path"] = filePath;
var files = Directory.GetFiles(filePath);
data["file_count"] = files.Length;
data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
}
else
{
data["exists"] = false;
data["path"] = filePath;
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private Dictionary<string, object> ExecuteEventLogRule(GatherRule rule)
{
var data = new Dictionary<string, object>();
var logName = rule.Target;
if (string.IsNullOrEmpty(logName))
return data;
try
{
int maxEntries = 10;
if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
{
int.TryParse(maxEntriesStr, out maxEntries);
maxEntries = Math.Min(maxEntries, 50); // Cap at 50
}
// Build XPath query with optional filters
var conditions = new List<string>();
string sourceFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
&& !string.IsNullOrEmpty(sourceFilter))
{
conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
}
string eventIdFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
&& !string.IsNullOrEmpty(eventIdFilter))
{
conditions.Add($"EventID={eventIdFilter}");
}
string xpath = conditions.Count > 0
? $"*[System[{string.Join(" and ", conditions)}]]"
: "*";
string messageFilter = null;
rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
var query = new EventLogQuery(logName, PathType.LogName, xpath)
{
ReverseDirection = true // newest first
};
data["log_name"] = logName;
var entries = new List<string>();
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
{
using (record)
{
var message = "";
try { message = record.FormatDescription() ?? ""; }
catch { /* Some events lack formatting resources */ }
// Apply message filter (wildcard contains)
if (!string.IsNullOrEmpty(messageFilter))
{
var pattern = messageFilter.Trim('*');
if (string.IsNullOrEmpty(pattern) ||
message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
continue;
}
var level = "";
try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
catch { level = record.Level?.ToString() ?? "Unknown"; }
var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
}
}
}
data["entries"] = string.Join("\n", entries);
data["entries_returned"] = entries.Count;
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private static string EscapeXPath(string value)
{
return value.Replace("'", "&apos;");
}
private void ExecuteLogParserRule(GatherRule rule)
{
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return;
filePath = Environment.ExpandEnvironmentVariables(filePath);
// Apply IME log path override if set
if (!string.IsNullOrEmpty(_imeLogPathOverride))
{
var fileName = Path.GetFileName(filePath);
filePath = Path.Combine(_imeLogPathOverride, fileName);
}
// Get parameters
string patternStr;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
string.IsNullOrEmpty(patternStr))
{
_logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
return;
}
string trackPositionStr;
bool trackPosition = true;
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
bool.TryParse(trackPositionStr, out trackPosition);
string maxLinesStr;
int maxLines = 1000;
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
int.TryParse(maxLinesStr, out maxLines);
Regex pattern;
try
{
pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
_logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
return;
}
if (!File.Exists(filePath))
{
_logger.Debug($"LogParser rule {rule.RuleId}: file not found: {filePath}");
return;
}
try
{
var fileInfo = new FileInfo(filePath);
long startPosition = trackPosition
? _filePositionTracker.GetSafePosition(filePath, fileInfo.Length)
: 0;
// Nothing new to read
if (startPosition >= fileInfo.Length)
return;
int matchCount = 0;
int linesRead = 0;
long endPosition = startPosition;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPosition, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
string line;
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
{
linesRead++;
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
continue;
Match match;
try
{
match = pattern.Match(entry.Message);
}
catch (RegexMatchTimeoutException)
{
continue;
}
if (!match.Success)
continue;
// Build data dictionary from named capture groups
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue; // Skip full match group
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
// Add CMTrace metadata
data["logTimestamp"] = entry.Timestamp.ToString("o");
data["logComponent"] = entry.Component;
data["logType"] = entry.Type;
data["logMessage"] = TruncateMessage(entry.Message, 500);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = entry.Timestamp,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
}
// Capture the final stream position after reading
endPosition = stream.Position;
}
}
if (trackPosition)
_filePositionTracker.SetPosition(filePath, endPosition);
if (matchCount > 0)
_logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
}
catch (Exception ex)
{
_logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
}
}
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
{
// If the rule specifies a severity, use it
if (!string.IsNullOrEmpty(ruleOverride))
{
switch (ruleOverride.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
}
}
// Otherwise derive from CMTrace log type
switch (cmTraceType)
{
case 2: return EventSeverity.Warning;
case 3: return EventSeverity.Error;
default: return EventSeverity.Info;
}
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
/// <summary>
/// Emits a security_warning event and returns an empty result dictionary.
/// Called when a gather rule targets a path/query not on the allowlist.
/// </summary>
private Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
{
_logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
var data = new Dictionary<string, object>
{
["blocked"] = true,
["reason"] = $"{collectorType} target not on allowlist",
["target"] = target,
["ruleId"] = rule.RuleId,
};
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
Data = data
});
return new Dictionary<string, object>();
}
private EventSeverity ParseSeverity(string severity)
{
if (string.IsNullOrEmpty(severity))
return EventSeverity.Info;
switch (severity.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
default: return EventSeverity.Info;
}
}
private void StopAllTimers()
{
foreach (var timer in _intervalTimers.Values)
{
timer.Dispose();
}
_intervalTimers.Clear();
}
/// <summary>
/// Blocks until all startup rules that were queued by the most recent <see cref="UpdateRules"/>
/// call have finished executing, or the timeout elapses.
/// Returns true if all rules completed within the timeout; false if timed out.
/// </summary>
public bool WaitForStartupRules(int timeoutSeconds = 120)
{
var latch = _startupRulesLatch;
if (latch == null)
return true; // no startup rules were queued
_logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
_logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
return completed;
}
public void Dispose()
{
StopAllTimers();
_startupRulesLatch?.Dispose();
_startupRulesLatch = null;
}
}
}
@@ -0,0 +1,641 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Analyzers;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Agent.Core.Monitoring.Replay;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Partial: Collector lifecycle management (start/stop optional collectors, idle timeout,
/// max lifetime timer, gather rules, analyzers, session registration).
/// </summary>
public partial class MonitoringService
{
private void StartOptionalCollectors()
{
var config = _remoteConfigService?.CurrentConfig;
var collectors = config?.Collectors;
_logger.Info("Starting collectors based on remote config");
try
{
// PerformanceCollector is always on (feeds UI chart)
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
_performanceCollector = new PerformanceCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
perfInterval
);
_performanceCollector.Start();
// AgentSelfMetricsCollector: measures the agent's own CPU, memory, and network footprint
if (collectors?.EnableAgentSelfMetrics != false)
{
var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
_agentSelfMetricsCollector = new AgentSelfMetricsCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_apiClient.NetworkMetrics,
_logger,
_agentVersion,
selfMetricsInterval
);
_agentSelfMetricsCollector.Start();
}
// Collector idle timeout — stop periodic collectors when enrollment goes quiet
_collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
_lastRealEventTime = DateTime.UtcNow;
_collectorsIdleStopped = false;
if (_collectorIdleTimeoutMinutes > 0)
{
_idleCheckTimer = new Timer(
IdleCheckCallback,
null,
TimeSpan.FromSeconds(60),
TimeSpan.FromSeconds(60)
);
_logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
}
else
{
_logger.Info("Collector idle timeout disabled (0)");
}
// EnrollmentTracker: smart enrollment tracking with IME log parsing
// (replaces DownloadProgressCollector + EspUiStateCollector)
// Skip when log replay is active - LogReplayService starts its own EnrollmentTracker
if (string.IsNullOrEmpty(_configuration.ReplayLogDir))
{
var imeLogPatterns = config?.ImeLogPatterns;
var imeMatchLogPath = string.IsNullOrEmpty(_configuration.ImeMatchLogPath)
? null
: Environment.ExpandEnvironmentVariables(_configuration.ImeMatchLogPath);
_enrollmentTracker = new EnrollmentTracker(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
imeLogPatterns,
_configuration.ImeLogPathOverride,
imeMatchLogPath: imeMatchLogPath,
espAndHelloTracker: _espAndHelloTracker,
isBootstrapMode: _configuration.UseBootstrapTokenAuth
);
_enrollmentTracker.Start();
}
// Desktop arrival detector — detects real user desktop for no-ESP completion
_desktopArrivalDetector = new DesktopArrivalDetector(_logger);
_desktopArrivalDetector.DesktopArrived += OnDesktopArrived;
_desktopArrivalDetector.Start();
// Agent max lifetime safety net — prevents zombie agents
var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes;
if (maxLifetimeMinutes > 0)
{
_maxLifetimeTimer = new Timer(
MaxLifetimeTimerCallback,
null,
TimeSpan.FromMinutes(maxLifetimeMinutes),
Timeout.InfiniteTimeSpan);
_logger.Info($"Agent max lifetime timer armed: {maxLifetimeMinutes} min");
}
else
{
_logger.Info("Agent max lifetime timer disabled (0)");
}
}
catch (Exception ex)
{
_logger.Error("Error starting optional collectors", ex);
}
}
/// <summary>
/// Stops all optional collectors
/// </summary>
private void StopOptionalCollectors()
{
_idleCheckTimer?.Dispose();
_idleCheckTimer = null;
_maxLifetimeTimer?.Dispose();
_maxLifetimeTimer = null;
if (_desktopArrivalDetector != null)
{
_desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
_desktopArrivalDetector.Dispose();
_desktopArrivalDetector = null;
}
_performanceCollector?.Stop();
_performanceCollector?.Dispose();
_performanceCollector = null;
_agentSelfMetricsCollector?.Stop();
_agentSelfMetricsCollector?.Dispose();
_agentSelfMetricsCollector = null;
_enrollmentTracker?.Stop();
_enrollmentTracker?.Dispose();
_enrollmentTracker = null;
}
/// <summary>
/// Fires when the agent max lifetime expires. Emits enrollment_failed with failureType "agent_timeout".
/// The normal shutdown path (EmitEvent → enrollment_failed) handles everything from here.
/// </summary>
private void MaxLifetimeTimerCallback(object state)
{
if (_enrollmentTerminalEventSeen)
{
_logger.Debug("Max lifetime timer fired but terminal event already seen — ignoring");
return;
}
var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes;
_logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed");
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "enrollment_failed",
Severity = EventSeverity.Error,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time",
Data = new Dictionary<string, object>
{
{ "failureType", "agent_timeout" },
{ "failureSource", "max_lifetime_timer" },
{ "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) }
}
});
}
/// <summary>
/// Fires when DesktopArrivalDetector detects explorer.exe under a real user.
/// Emits desktop_arrived event for the timeline and notifies EnrollmentTracker.
/// </summary>
private void OnDesktopArrived(object sender, EventArgs e)
{
if (_enrollmentTerminalEventSeen)
{
_logger.Debug("Desktop arrived but terminal event already seen — ignoring");
return;
}
// Emit informational event for the timeline (shows when user desktop appeared)
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "desktop_arrived",
Severity = EventSeverity.Info,
Source = "DesktopArrivalDetector",
Phase = EnrollmentPhase.Unknown,
Message = "User desktop detected (explorer.exe under real user)"
});
// Notify EnrollmentTracker — it decides whether this triggers completion
_enrollmentTracker?.NotifyDesktopArrived();
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity)
/// </summary>
private static bool IsPeriodicEvent(string eventType)
{
return eventType == "performance_snapshot" ||
eventType == "agent_metrics_snapshot" ||
eventType == "performance_collector_stopped" ||
eventType == "agent_metrics_collector_stopped";
}
/// <summary>
/// Periodic check (every 60s) whether enrollment has gone idle.
/// When no real event has arrived within the configured timeout window,
/// periodic collectors are stopped to prevent session bloat.
/// </summary>
private void IdleCheckCallback(object state)
{
if (_collectorsIdleStopped)
return;
var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
if (idleMinutes < _collectorIdleTimeoutMinutes)
return;
_logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors");
// Stop PerformanceCollector
if (_performanceCollector != null)
{
_performanceCollector.Stop();
_performanceCollector.Dispose();
_performanceCollector = null;
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "performance_collector_stopped",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Unknown,
Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
Data = new Dictionary<string, object>
{
{ "reason", "idle_timeout" },
{ "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
{ "idleMinutes", Math.Round(idleMinutes, 1) }
}
});
}
// Stop AgentSelfMetricsCollector
if (_agentSelfMetricsCollector != null)
{
_agentSelfMetricsCollector.Stop();
_agentSelfMetricsCollector.Dispose();
_agentSelfMetricsCollector = null;
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "agent_metrics_collector_stopped",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Unknown,
Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
Data = new Dictionary<string, object>
{
{ "reason", "idle_timeout" },
{ "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
{ "idleMinutes", Math.Round(idleMinutes, 1) }
}
});
}
_collectorsIdleStopped = true;
// Stop the idle check timer — it will be restarted if collectors resume
_idleCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
/// <summary>
/// Restarts periodic collectors after they were stopped due to idle timeout.
/// Called when new real enrollment activity is detected.
/// </summary>
private void RestartPeriodicCollectors()
{
var config = _remoteConfigService?.CurrentConfig;
var collectors = config?.Collectors;
_logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop");
try
{
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
_performanceCollector = new PerformanceCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
perfInterval
);
_performanceCollector.Start();
if (collectors?.EnableAgentSelfMetrics != false)
{
var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
_agentSelfMetricsCollector = new AgentSelfMetricsCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_apiClient.NetworkMetrics,
_logger,
_agentVersion,
selfMetricsInterval
);
_agentSelfMetricsCollector.Start();
}
// Reset idle tracking and restart the check timer
_lastRealEventTime = DateTime.UtcNow;
_collectorsIdleStopped = false;
if (_collectorIdleTimeoutMinutes > 0)
{
_idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
}
catch (Exception ex)
{
_logger.Error("Error restarting periodic collectors", ex);
}
}
/// <summary>
/// Starts the gather rule executor with rules from remote config
/// </summary>
private void StartGatherRuleExecutor()
{
var config = _remoteConfigService?.CurrentConfig;
if (config?.GatherRules == null || config.GatherRules.Count == 0)
{
_logger.Info("No gather rules to execute");
return;
}
try
{
_gatherRuleExecutor = new GatherRuleExecutor(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
_configuration.ImeLogPathOverride
);
_gatherRuleExecutor.UpdateRules(config.GatherRules);
}
catch (Exception ex)
{
_logger.Error("Error starting gather rule executor", ex);
}
}
/// <summary>
/// Initializes agent analyzers from remote config.
/// Called once during Start(), after all collectors are started.
/// </summary>
private void InitializeAnalyzers()
{
_analyzers.Clear();
var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration();
if (analyzerConfig.EnableLocalAdminAnalyzer)
{
_analyzers.Add(new LocalAdminAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
analyzerConfig.LocalAdminAllowedAccounts
));
_logger.Info("LocalAdminAnalyzer registered");
}
else
{
_logger.Info("LocalAdminAnalyzer disabled by remote config");
}
_logger.Info($"Analyzers initialized: {_analyzers.Count} active");
}
/// <summary>
/// Runs all registered analyzers at agent startup — asynchronously on the ThreadPool
/// so they do not block or delay the agent start sequence.
/// Emitted events will be picked up by the spool watcher and uploaded normally.
/// </summary>
private void RunStartupAnalyzers()
{
if (_analyzers.Count == 0)
return;
// Capture the list snapshot — _analyzers is not modified after Start()
var analyzers = new List<IAgentAnalyzer>(_analyzers);
_logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread");
Task.Run(() =>
{
foreach (var analyzer in analyzers)
{
try
{
analyzer.AnalyzeAtStartup();
}
catch (Exception ex)
{
_logger.Error($"Analyzer {analyzer.Name} threw during startup", ex);
}
}
});
}
/// <summary>
/// Runs all registered analyzers at shutdown / enrollment completion.
/// Emits end-state findings for delta detection against the startup results.
/// </summary>
private void RunShutdownAnalyzers()
{
if (_analyzers.Count == 0)
return;
_logger.Info($"Running {_analyzers.Count} shutdown analyzer(s)");
foreach (var analyzer in _analyzers)
{
try
{
analyzer.AnalyzeAtShutdown();
}
catch (Exception ex)
{
_logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex);
}
}
}
/// <summary>
/// Registers the session with the backend. Retries with exponential backoff
/// to handle network not being ready during OOBE boot (WhiteGlove Part 2).
/// </summary>
private async Task RegisterSessionAsync()
{
const int maxAttempts = 5;
var registration = new SessionRegistration
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
SerialNumber = DeviceInfoProvider.GetSerialNumber(),
Manufacturer = DeviceInfoProvider.GetManufacturer(),
Model = DeviceInfoProvider.GetModel(),
DeviceName = Environment.MachineName,
OsBuild = Environment.OSVersion.Version.ToString(),
OsEdition = DeviceInfoProvider.GetOsEdition(),
OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
StartedAt = DateTime.UtcNow,
AgentVersion = _agentVersion,
EnrollmentType = EnrollmentTracker.DetectEnrollmentTypeStatic(),
IsHybridJoin = EnrollmentTracker.DetectHybridJoinStatic(),
IsUserDriven = true
};
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
_logger.Info($"Registering session with backend (attempt {attempt}/{maxAttempts})");
var response = await _apiClient.RegisterSessionAsync(registration);
if (response.Success)
{
_logger.Info($"Session registered successfully: {response.SessionId}");
return;
}
_logger.Warning($"Session registration failed: {response.Message}");
}
catch (BackendAuthException ex)
{
_logger.Error($"Session registration authentication failed: {ex.Message}");
HandleAuthFailure();
return; // Auth failure is not retryable
}
catch (Exception ex)
{
_logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex);
if (attempt == maxAttempts)
{
// Report to emergency channel on final failure
_ = _emergencyReporter.TrySendAsync(
AgentErrorType.RegisterSessionFailed,
ex.Message);
return;
}
}
// Exponential backoff: 2s, 4s, 8s, 16s
var delaySeconds = (int)Math.Pow(2, attempt);
_logger.Info($"Retrying session registration in {delaySeconds}s");
await Task.Delay(delaySeconds * 1000);
}
}
/// <summary>
/// Stops the monitoring service
/// </summary>
public void Stop()
{
_logger.Info("Stopping monitoring service");
// Stop FileSystemWatcher
_spool.StopWatching();
// Stop event collectors
StopEventCollectors();
// Run shutdown analyzers before final event (captures end-state for delta detection)
RunShutdownAnalyzers();
// Emit final event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "agent_stopped",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = _lastPhase ?? EnrollmentPhase.Unknown,
Message = "Autopilot Monitor Agent stopped"
});
// Final upload attempt
UploadEventsAsync().Wait(TimeSpan.FromSeconds(10));
_logger.Info("Monitoring service stopped");
// Unblock WaitForCompletion() in background/SYSTEM mode
_completionEvent.Set();
}
/// <summary>
/// Blocks the calling thread until the service stops (used when running as Scheduled Task
/// under SYSTEM where there is no interactive Console.ReadLine() to keep the process alive).
/// </summary>
public void WaitForCompletion()
{
_completionEvent.Wait();
}
/// <summary>
/// Triggers cleanup without running enrollment monitoring.
/// Used when enrollment complete marker is detected on startup (cleanup retry).
/// </summary>
public void TriggerCleanup()
{
_logger.Info("TriggerCleanup invoked - executing cleanup without enrollment monitoring");
if (_configuration.SelfDestructOnComplete)
{
_cleanupService.ExecuteSelfDestruct();
}
else
{
_logger.Info("SelfDestructOnComplete is disabled - nothing to clean up");
}
}
/// <summary>
/// Stops all event collection components
/// </summary>
private void StopEventCollectors()
{
_logger.Info("Stopping event collectors");
try
{
_espAndHelloTracker?.Stop();
_espAndHelloTracker?.Dispose();
_logReplay?.Stop();
_logReplay?.Dispose();
// Stop optional collectors
StopOptionalCollectors();
// Stop gather rule executor
_gatherRuleExecutor?.Dispose();
_gatherRuleExecutor = null;
_logger.Info("Event collectors stopped");
}
catch (Exception ex)
{
_logger.Error("Error stopping event collectors", ex);
}
}
/// <summary>
/// Emits an event (adds to spool)
/// </summary>
}
}
@@ -0,0 +1,565 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Partial: Event emission, upload orchestration, auth failure handling,
/// enrollment/WhiteGlove completion, and diagnostics upload.
/// </summary>
public partial class MonitoringService
{
public void EmitEvent(EnrollmentEvent evt)
{
evt.Sequence = Interlocked.Increment(ref _eventSequence);
_spool.Add(evt);
_logger.Info($"Event emitted: {evt.EventType} - {evt.Message}");
// Track real enrollment activity for idle timeout
if (!string.IsNullOrEmpty(evt.EventType) && !IsPeriodicEvent(evt.EventType))
{
_lastRealEventTime = DateTime.UtcNow;
// Restart periodic collectors if they were stopped due to idle
if (_collectorsIdleStopped)
{
RestartPeriodicCollectors();
}
}
// Persist sequence periodically (every 50 events) + always on critical events
if (evt.Sequence % 50 == 0 ||
evt.EventType == "whiteglove_complete" ||
evt.EventType == "enrollment_complete" ||
evt.EventType == "enrollment_failed")
{
_sessionPersistence.SaveSequence(evt.Sequence);
}
// Cancel max lifetime timer on terminal events
if (evt.EventType == "whiteglove_complete" ||
evt.EventType == "enrollment_complete" ||
evt.EventType == "enrollment_failed")
{
_enrollmentTerminalEventSeen = true;
_maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
// Check if this is a phase transition
bool isPhaseTransition = false;
if (evt.Phase != _lastPhase)
{
_logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}");
_lastPhase = evt.Phase;
isPhaseTransition = true;
// Notify gather rule executor of phase change
try { _gatherRuleExecutor?.OnPhaseChanged(evt.Phase); } catch { }
}
// Notify gather rule executor of event type (for on_event triggers)
if (!string.IsNullOrEmpty(evt.EventType))
{
try { _gatherRuleExecutor?.OnEvent(evt.EventType); } catch { }
}
// Check for WhiteGlove completion — agent exits gracefully, session stays open
if (evt.EventType == "whiteglove_complete")
{
_logger.Info("WhiteGlove pre-provisioning complete. Starting graceful shutdown sequence.");
// Stop performance collection immediately (no value while device is off)
_performanceCollector?.Stop();
_performanceCollector?.Dispose();
_performanceCollector = null;
// Stop all event collectors and the spool watcher — no new events after this point
StopEventCollectors();
_spool.StopWatching();
// Stop upload timers — we drive uploads manually from here
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
Task.Run(() => HandleWhiteGloveComplete());
return; // Don't continue with normal event processing
}
// Check for enrollment completion events
if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed")
{
var enrollmentSucceeded = evt.EventType == "enrollment_complete";
_logger.Info($"Enrollment completion detected: {evt.EventType}");
// Delete session ID so a new session will be created on next enrollment
DeleteSessionId();
// Stop ALL collectors to minimize system impact
_logger.Info("Stopping all collectors after enrollment completion...");
StopEventCollectors();
// Stop upload timers - no more periodic uploads needed
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
// Final upload to flush any remaining events
Task.Run(async () =>
{
try
{
await UploadEventsAsync();
_logger.Info("Final event upload after enrollment completion done");
}
catch (Exception ex)
{
_logger.Warning($"Final upload after enrollment completion failed: {ex.Message}");
}
});
// Trigger self-destruct or reboot if configured
if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete)
{
Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded));
return; // Don't continue with normal event processing
}
// No self-destruct/reboot — still upload diagnostics if configured
Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded));
}
// Immediate upload for:
// 1. Critical events (errors) - for troubleshooting
// 2. Phase transitions (start/end) - for real-time phase tracking in UI
// 3. Events with "phase" in EventType - explicit phase-related events
// 4. App download/install events - for real-time download progress UI updates
var isAppEvent = evt.EventType?.StartsWith("app_", StringComparison.OrdinalIgnoreCase) == true;
if (evt.Severity >= EventSeverity.Error ||
isPhaseTransition ||
evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true ||
isAppEvent)
{
_logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)");
Task.Run(() => UploadEventsAsync());
}
}
/// <summary>
/// Called when new events are available in the spool (FileSystemWatcher detected new files)
/// Uses debouncing to batch events before uploading
/// </summary>
private void OnEventsAvailable(object sender, EventArgs e)
{
_logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
// Reset debounce timer - wait for batch window before uploading
// This allows multiple events to accumulate, reducing API calls
lock (_timerLock)
{
_debounceTimer.Change(
TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
Timeout.InfiniteTimeSpan
);
}
}
/// <summary>
/// Called when debounce timer expires - uploads batched events
/// </summary>
private void DebounceTimerCallback(object state)
{
_logger.Debug("Debounce timer expired, uploading batched events");
Task.Run(() => UploadEventsAsync());
}
/// <summary>
/// Fallback timer callback in case FileSystemWatcher misses events
/// </summary>
private void UploadTimerCallback(object state)
{
Task.Run(() => UploadEventsAsync());
}
private async Task UploadEventsAsync()
{
// Prevent concurrent uploads — if one is already in flight, skip this call.
// The events will be picked up by the next trigger or debounce timer.
if (!_uploadSemaphore.Wait(0))
return;
try
{
var events = _spool.GetBatch(_configuration.MaxBatchSize);
if (events.Count == 0)
{
_logger.Debug("No events to upload");
return;
}
var request = new IngestEventsRequest
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Events = events
};
var response = await _apiClient.IngestEventsAsync(request);
if (response.DeviceKillSignal)
{
_logger.Warning("=== REMOTE KILL SIGNAL received from administrator. Initiating self-destruct... ===");
// Stop all timers — no further uploads
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
// Force self-destruct regardless of current config, respecting KeepLogFile from tenant config
_configuration.SelfDestructOnComplete = true;
_cleanupService.ExecuteSelfDestruct();
Environment.Exit(0);
return;
}
if (response.DeviceBlocked)
{
var unblockMsg = response.UnblockAt.HasValue
? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC"
: string.Empty;
_logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ===");
// Stop upload timers — no further uploads for the duration of this run.
// The block is temporary; the next agent run (after reboot / re-enrollment) will check again.
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
return;
}
if (response.Success)
{
_spool.RemoveEvents(events);
_logger.Info($"Uploaded {response.EventsProcessed} events");
// Reset failure counters on success
_consecutiveAuthFailures = 0;
_firstAuthFailureTime = null;
_consecutiveUploadFailures = 0;
}
else
{
_logger.Warning($"Upload failed: {response.Message}");
}
}
catch (BackendAuthException ex)
{
_logger.Error($"Upload authentication failed: {ex.Message}");
HandleAuthFailure();
}
catch (Exception ex)
{
_consecutiveUploadFailures++;
_logger.Error("Error uploading events", ex);
// After N consecutive non-auth failures, signal the emergency channel once.
// The EmergencyReporter deduplicates further calls for the same error category.
if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold)
{
_ = _emergencyReporter.TrySendAsync(
AgentErrorType.IngestFailed,
ex.Message,
httpStatusCode: null,
sequenceNumber: _eventSequence);
}
}
finally
{
_uploadSemaphore.Release();
}
}
/// <summary>
/// Handles an authentication failure by incrementing the counter and shutting down
/// the agent if the configured threshold is reached.
/// </summary>
private void HandleAuthFailure()
{
_consecutiveAuthFailures++;
if (_firstAuthFailureTime == null)
_firstAuthFailureTime = DateTime.UtcNow;
_logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
(_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
$" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
// Check max attempts (0 = disabled)
if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
{
_logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
"The device is not authorized to send data to Autopilot Monitor. " +
"Check client certificate and Autopilot device validation in your tenant configuration. ===");
Environment.Exit(1);
}
// Check timeout (0 = disabled)
if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
{
var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
{
_logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
$"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
"The device is not authorized to send data to Autopilot Monitor. " +
"Check client certificate and Autopilot device validation in your tenant configuration. ===");
Environment.Exit(1);
}
}
}
/// <summary>
/// Deletes the persisted session ID and sequence files.
/// Should be called when enrollment is complete/failed.
/// </summary>
private void DeleteSessionId()
{
_logger.Info("Deleting persisted session ID and sequence...");
try
{
_sessionPersistence.DeleteSession(); // Deletes session.id AND session.seq
_logger.Info("Session ID and sequence deleted successfully");
}
catch (Exception ex)
{
_logger.Warning($"Failed to delete session ID: {ex.Message}");
}
}
/// <summary>
/// Uploads diagnostics package with timeline events (start + result).
/// Emits events, uploads them, then performs the actual diagnostics upload.
/// </summary>
private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
return;
if (!_configuration.DiagnosticsUploadEnabled)
return;
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
return;
// Emit "collecting" event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "diagnostics_collecting",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
Data = new Dictionary<string, object>
{
{ "uploadMode", mode }
}
});
await UploadEventsAsync();
// Perform the actual ZIP + upload
var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
var success = uploadResult?.Success == true;
// Emit result event (includes blobName so backend can store it on the session)
var resultData = new Dictionary<string, object>
{
{ "uploadMode", mode }
};
if (uploadResult?.BlobName != null)
resultData["blobName"] = uploadResult.BlobName;
if (uploadResult?.SasUrlPrefix != null)
resultData["sasUrl"] = uploadResult.SasUrlPrefix;
if (uploadResult?.ErrorCode != null)
resultData["errorCode"] = uploadResult.ErrorCode;
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
Severity = success ? EventSeverity.Info : EventSeverity.Warning,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = success
? $"Diagnostics package uploaded: {uploadResult.BlobName}"
: $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
Data = resultData
});
await UploadEventsAsync();
}
/// <summary>
/// Handles enrollment completion and triggers self-destruct sequence
/// </summary>
private async Task HandleEnrollmentComplete(bool enrollmentSucceeded)
{
try
{
_logger.Info("===== ENROLLMENT COMPLETE - Starting Self-Destruct Sequence =====");
// Step 1: Stop all event collectors
_logger.Info("Stopping event collectors...");
StopEventCollectors();
_spool.StopWatching();
// Step 1.5: Run shutdown analyzers to capture end-state (delta from startup)
RunShutdownAnalyzers();
// Step 2: Upload all remaining events (includes analyzer findings)
_logger.Info("Uploading final events...");
await UploadEventsAsync();
// Give a moment for final upload to complete
await Task.Delay(2000);
// Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot)
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
// Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only
if (_configuration.SelfDestructOnComplete)
{
_cleanupService.ExecuteSelfDestruct();
}
else if (_configuration.RebootOnComplete)
{
_logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
// Emit reboot event into the timeline before shutdown
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "reboot_triggered",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
Data = new Dictionary<string, object>
{
{ "rebootDelaySeconds", _configuration.RebootDelaySeconds }
}
});
// Final upload to ensure the reboot event is sent
await UploadEventsAsync();
var psi = new ProcessStartInfo
{
FileName = "shutdown.exe",
Arguments = $"/r /t {_configuration.RebootDelaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(psi);
}
_logger.Info("Self-destruct sequence initiated. Agent will now exit.");
// Exit the application
Environment.Exit(0);
}
catch (Exception ex)
{
_logger.Error("Error during self-destruct sequence", ex);
Environment.Exit(1);
}
}
/// <summary>
/// Graceful shutdown sequence after WhiteGlove (Pre-Provisioning) completes.
/// Mirrors HandleEnrollmentComplete but does NOT delete the session ID or write the
/// enrollment-complete marker — the session must survive for Part 2 (user enrollment).
/// Uploads a "preprov" diagnostics package if diagnostics are configured.
/// </summary>
private async Task HandleWhiteGloveComplete()
{
try
{
_logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
// Step 1: Drain the entire spool — whiteglove_complete may be beyond the first
// MaxBatchSize (100) events, so loop until empty.
_logger.Info("Uploading final events (draining spool)...");
int maxIterations = 20; // safety cap against persistent upload failures
while (_spool.GetCount() > 0 && maxIterations-- > 0)
{
await UploadEventsAsync();
}
// Step 2: Small delay to ensure final network transmission completes
await Task.Delay(2000);
// Step 3: Upload pre-provisioning diagnostics package if configured.
// Pass enrollmentSucceeded=true (pre-prov succeeded) and suffix "preprov"
// so the blob is named AgentDiagnostics-{sessionId}-{ts}-preprov.zip,
// distinguishable from the later full-enrollment package.
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
// Step 4: Persist final sequence for Boot 2 continuation
_sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
_logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
// Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
// can signal the backend to transition Pending → InProgress.
_sessionPersistence.SaveWhiteGloveComplete();
_logger.Info("WhiteGlove marker persisted for Part 2 detection");
// Step 6: Exit — session.id + session.seq + whiteglove.complete survive so
// the agent resumes on next boot.
// No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct.
// The Windows Autopilot shutdown will power off the device;
// the Scheduled Task restarts the agent on the user's first boot.
_logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
Environment.Exit(0);
}
catch (Exception ex)
{
_logger.Error("Error during WhiteGlove shutdown sequence", ex);
Environment.Exit(0); // Still exit — don't leave a zombie process
}
}
public void Dispose()
{
_uploadTimer?.Dispose();
_debounceTimer?.Dispose();
_idleCheckTimer?.Dispose();
_apiClient?.Dispose();
_spool?.Dispose();
_espAndHelloTracker?.Dispose();
_logReplay?.Dispose();
_performanceCollector?.Dispose();
_agentSelfMetricsCollector?.Dispose();
_enrollmentTracker?.Dispose();
_gatherRuleExecutor?.Dispose();
_remoteConfigService?.Dispose();
_completionEvent.Dispose();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,589 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Partial: Enrollment completion detection, Hello/WhiteGlove/ESP failure handling,
/// state persistence, and multi-signal completion logic.
/// </summary>
public partial class EnrollmentTracker
{
private void OnHelloCompleted(object sender, EventArgs e)
{
_logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker");
_stateData.HelloResolvedUtc = DateTime.UtcNow;
RecordSignal("hello_resolved");
// Cancel safety timer — Hello resolved normally
_waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
if (_isWaitingForHello)
{
_logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now");
_isWaitingForHello = false;
TryEmitEnrollmentComplete("ime_hello");
}
else if (_espFinalExitSeen)
{
_logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion");
TryEmitEnrollmentComplete("esp_hello_composite");
}
else if (_desktopArrived)
{
_logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion");
TryEmitEnrollmentComplete("desktop_hello");
}
else
{
_logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet");
}
}
/// <summary>
/// Called when WhiteGlove (Pre-Provisioning) completes successfully.
/// Emits the whiteglove_complete event. The MonitoringService handles
/// the actual agent shutdown upon seeing this event type.
/// </summary>
private void OnWhiteGloveCompleted(object sender, EventArgs e)
{
_logger.Info("EnrollmentTracker: WhiteGlove pre-provisioning completed");
// Stop summary timer — no more app tracking needed
_summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
_summaryTimerActive = false;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "whiteglove_complete",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Complete,
Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state"
});
// WICHTIG: Kein WriteEnrollmentCompleteMarker()!
// Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft.
// WICHTIG: Kein _imeLogTracker?.DeleteState()!
// Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss.
}
/// <summary>
/// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
/// Terminal failures emit enrollment_failed immediately.
/// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure.
/// </summary>
private void OnEspFailureDetected(object sender, string failureType)
{
_logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}");
if (RecoverableEspFailureTypes.Contains(failureType))
{
// Recoverable failure — start grace period timer
_logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period");
_pendingEspFailureType = failureType;
// Cancel existing timer if any (e.g. second timeout event)
_espFailureTimer?.Dispose();
_espFailureTimer = new Timer(
OnEspFailureGracePeriodExpired,
null,
TimeSpan.FromSeconds(EspFailureGracePeriodSeconds),
TimeSpan.FromMilliseconds(-1));
}
else
{
// Terminal failure — emit enrollment_failed immediately
_logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately");
EmitEnrollmentFailed(failureType, "esp_failure");
}
}
/// <summary>
/// Called when the ESP failure grace period expires without recovery.
/// Emits enrollment_failed.
/// </summary>
private void OnEspFailureGracePeriodExpired(object state)
{
var failureType = _pendingEspFailureType ?? "unknown";
_logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed");
_pendingEspFailureType = null;
EmitEnrollmentFailed(failureType, "esp_failure_grace_expired");
}
/// <summary>
/// Cancels any pending ESP failure grace period timer (called when recovery is detected).
/// </summary>
private void CancelPendingEspFailure()
{
if (_espFailureTimer != null)
{
_logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{_pendingEspFailureType}'");
_espFailureTimer.Dispose();
_espFailureTimer = null;
_pendingEspFailureType = null;
}
}
/// <summary>
/// Safety-net callback: fires 7 minutes after waiting_for_hello was set.
/// If the normal Hello timeout chain (30s + 300s) failed for any reason,
/// this forces enrollment_complete instead of waiting for the 6h max lifetime timer.
/// </summary>
private void OnWaitingForHelloSafetyTimeout(object state)
{
if (!_isWaitingForHello || _enrollmentCompleteEmitted)
return;
_logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion");
_isWaitingForHello = false;
// Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes
if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
{
_espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout");
}
TryEmitEnrollmentComplete("ime_hello_safety_timeout");
}
/// <summary>
/// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete.
/// </summary>
private void EmitEnrollmentFailed(string failureType, string failureSource)
{
// Stop timers
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_failed",
Severity = EventSeverity.Error,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Complete,
Message = $"Autopilot enrollment failed: {failureType}",
Data = new Dictionary<string, object>
{
{ "failureType", failureType },
{ "failureSource", failureSource },
{ "signalsSeen", _stateData.SignalsSeen },
{ "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
}
});
// Write final status dump before cleanup
WriteFinalStatus("failed", failureType);
// Clean up persisted tracker state
_imeLogTracker?.DeleteState();
_statePersistence.Delete();
}
/// <summary>
/// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events)
/// Triggers transition to FinalizingSetup phase
/// </summary>
private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
{
_logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}");
// If ESP exiting, check which phase we're in
if (reason == "esp_exiting")
{
_espEverSeen = true;
if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) || _desktopArrived)
{
// Final ESP exit: either AccountSetup phase detected OR desktop arrived (backup)
var phaseInfo = string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
? "AccountSetup"
: $"{_lastEspPhase ?? "unknown"} (desktop arrival backup)";
_logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer");
_espFinalExitSeen = true;
_stateData.EspFinalExitSeen = true;
_stateData.EspFinalExitUtc = DateTime.UtcNow;
RecordSignal("esp_final_exit");
// Emit phase change event to FinalizingSetup
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.FinalizingSetup,
Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)",
Data = new Dictionary<string, object>
{
{ "espPhase", "FinalizingSetup" },
{ "autoDetected", true },
{ "triggeredBy", reason },
{ "previousPhase", _lastEspPhase ?? "unknown" },
{ "desktopArrivedBackup", _desktopArrived && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) }
}
});
CollectDeviceInfoAtFinalizingSetup(reason);
// Start Hello wait timer (waits for Hello wizard to start or timeout)
_espAndHelloTracker?.StartHelloWaitTimer();
// If Hello was already resolved (e.g., via EventLog backfill or Event 300/301
// during AccountSetup), the composite signal can fire immediately.
TryEmitEnrollmentComplete("esp_hello_composite");
}
else if (_skipUserStatusPage == true)
{
// Registry definitively says no AccountSetup expected → immediate device-only classification
_logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)");
_espFinalExitSeen = true;
_stateData.EspFinalExitSeen = true;
_stateData.EspFinalExitUtc = DateTime.UtcNow;
RecordSignal("device_only_esp_registry");
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.FinalizingSetup,
Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)",
Data = new Dictionary<string, object>
{
{ "espPhase", "FinalizingSetup" },
{ "autoDetected", true },
{ "triggeredBy", "device_only_esp_registry" },
{ "previousPhase", _lastEspPhase ?? "unknown" },
{ "skipUserStatusPage", true }
}
});
CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry");
_espAndHelloTracker?.StartHelloWaitTimer();
TryEmitEnrollmentComplete("device_only_esp_registry");
}
else
{
// Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection
var fallbackReason = _skipUserStatusPage == null ? "registry keys not found" : "SkipUserStatusPage=false";
_logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)");
// Start device-only ESP detection timer
_deviceOnlyEspTimer?.Dispose();
_deviceOnlyEspTimer = new Timer(
OnDeviceOnlyEspTimerExpired,
null,
TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes),
TimeSpan.FromMilliseconds(-1));
}
}
else if (reason == "hello_wizard_started")
{
// Hello wizard started - transition to FinalizingSetup regardless of previous phase
_logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase");
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.FinalizingSetup,
Message = "ESP phase: FinalizingSetup (Hello wizard started)",
Data = new Dictionary<string, object>
{
{ "espPhase", "FinalizingSetup" },
{ "autoDetected", true },
{ "triggeredBy", reason }
}
});
CollectDeviceInfoAtFinalizingSetup(reason);
}
}
// ===== State Persistence =====
private void LoadState()
{
var loaded = _statePersistence.Load();
if (loaded == null)
return;
_espEverSeen = loaded.EspEverSeen;
_espFinalExitSeen = loaded.EspFinalExitSeen;
_desktopArrived = loaded.DesktopArrived;
_lastEspPhase = loaded.LastEspPhase;
_isWaitingForHello = loaded.IsWaitingForHello;
_enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted;
_enrollmentType = loaded.EnrollmentType ?? _enrollmentType;
_skipUserStatusPage = loaded.SkipUserStatusPage;
_skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
_stateData = loaded;
_logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}");
// Restart Hello wait timer if needed after crash recovery
if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
{
_logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery");
_espAndHelloTracker.StartHelloWaitTimer();
}
// Restart safety timer if we were waiting for Hello
if (_isWaitingForHello && !_enrollmentCompleteEmitted)
{
_logger.Info("EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery");
_waitingForHelloSafetyTimer?.Dispose();
_waitingForHelloSafetyTimer = new Timer(
OnWaitingForHelloSafetyTimeout,
null,
TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
}
}
private void RecordSignal(string signal)
{
if (!_stateData.SignalsSeen.Contains(signal))
_stateData.SignalsSeen.Add(signal);
_stateDirty = true;
}
// ===== Unified Completion Logic =====
/// <summary>
/// Central guard method for enrollment_complete emission. All completion paths route through here.
/// An _enrollmentCompleteEmitted flag prevents double emission.
/// Emits a throttled completion_check event on every call for observability.
/// </summary>
private void TryEmitEnrollmentComplete(string source)
{
if (_enrollmentCompleteEmitted)
{
_logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping");
return;
}
if (string.IsNullOrEmpty(source))
return;
// Hello-Check: Hello must be resolved before we can complete
bool helloResolved = _espAndHelloTracker == null
|| _espAndHelloTracker.IsHelloCompleted
|| !_espAndHelloTracker.IsPolicyConfigured;
if (!helloResolved)
{
_logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet");
EmitCompletionCheck(source, "hello_pending", "hello_not_resolved");
return;
}
// Desktop-Arrival Gate: Block desktop-based completion when ESP is still actively running.
// Both "desktop_arrival" (direct) and "desktop_hello" (Hello resolved after desktop arrival)
// must be gated — otherwise Hello timeout during active AccountSetup triggers premature completion.
// WDP v2 has no ESP — skip the gate entirely.
if ((source == "desktop_arrival" || source == "desktop_hello") && _enrollmentType != "v2" && _espEverSeen && !_espFinalExitSeen)
{
_logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active");
EmitCompletionCheck(source, "blocked", "esp_active");
return;
}
_enrollmentCompleteEmitted = true;
_stateData.EnrollmentCompleteEmitted = true;
// Stop timers
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown";
var signalTimestamps = new Dictionary<string, string>();
if (_stateData.EspFirstSeenUtc.HasValue)
signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
if (_stateData.DesktopArrivedUtc.HasValue)
signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
if (_stateData.EspFinalExitUtc.HasValue)
signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
if (_stateData.HelloResolvedUtc.HasValue)
signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
if (_stateData.ImePatternSeenUtc.HasValue)
signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_complete",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Complete,
Message = $"Autopilot enrollment completed successfully (source: {source})",
Data = new Dictionary<string, object>
{
{ "completionSource", source },
{ "helloOutcome", helloOutcome },
{ "signalsSeen", _stateData.SignalsSeen },
{ "signalTimestamps", signalTimestamps },
{ "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
}
});
// Write final status dump and enrollment complete marker
WriteFinalStatus("completed", source);
WriteEnrollmentCompleteMarker();
// Clean up persisted tracker state so next enrollment starts fresh
_imeLogTracker?.DeleteState();
_statePersistence.Delete();
}
/// <summary>
/// Emits a throttled completion_check event for observability.
/// Max 1 event per minute per source to avoid flooding.
/// </summary>
private void EmitCompletionCheck(string source, string result, string reason)
{
// Throttle: max 1x per minute per source
var now = DateTime.UtcNow;
if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60)
return;
_lastCompletionCheckBySource[source] = now;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "completion_check",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Completion source '{source}' evaluated — {result}",
Data = new Dictionary<string, object>
{
{ "source", source },
{ "result", result },
{ "reason", reason },
{ "espEverSeen", _espEverSeen },
{ "espFinalExitSeen", _espFinalExitSeen },
{ "desktopArrived", _desktopArrived },
{ "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
{ "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
{ "enrollmentType", _enrollmentType },
{ "lastEspPhase", _lastEspPhase ?? "none" },
{ "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
{ "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }
}
});
}
/// <summary>
/// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user).
/// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion.
/// </summary>
public void NotifyDesktopArrived()
{
if (_desktopArrived)
return;
_desktopArrived = true;
_stateData.DesktopArrived = true;
_stateData.DesktopArrivedUtc = DateTime.UtcNow;
RecordSignal("desktop_arrived");
_logger.Info("EnrollmentTracker: Desktop arrival notified");
// Phase correction: If ESP was seen but AccountSetup was never detected by IME log,
// correct the phase and emit an event for the timeline
if (_espEverSeen && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
var previousPhase = _lastEspPhase ?? "unknown";
_lastEspPhase = "AccountSetup";
_logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline");
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "DesktopArrivalDetector",
Phase = EnrollmentPhase.AccountSetup,
Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})",
Data = new Dictionary<string, object>
{
{ "espPhase", "AccountSetup" },
{ "autoDetected", true },
{ "correctedBy", "desktop_arrival" },
{ "previousPhase", previousPhase }
}
});
}
// Start Hello wait timer ONLY when ESP is NOT actively running.
// During active ESP (AccountSetup runs in background with desktop visible),
// the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered).
// Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion.
if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted
&& (!_espEverSeen || _espFinalExitSeen))
{
_logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer");
_espAndHelloTracker.StartHelloWaitTimer();
}
TryEmitEnrollmentComplete("desktop_arrival");
}
/// <summary>
/// Called when the device-only ESP timer expires. If no AccountSetup phase started
/// and desktop is available, classify as device-only ESP and mark final exit.
/// </summary>
private void OnDeviceOnlyEspTimerExpired(object state)
{
// AccountSetup started meanwhile? Timer is obsolete
if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
_logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring");
return;
}
if (_desktopArrived)
{
_logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active");
_espFinalExitSeen = true;
_stateData.EspFinalExitSeen = true;
_stateData.EspFinalExitUtc = DateTime.UtcNow;
RecordSignal("device_only_esp_final_exit");
_espAndHelloTracker?.StartHelloWaitTimer();
}
else
{
_logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer");
}
}
}
}
@@ -0,0 +1,327 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Partial: Device info collection at key lifecycle points, app tracking summaries,
/// debug state timer, and final status file writing.
/// </summary>
public partial class EnrollmentTracker
{
private void CollectDeviceInfoAtEnrollmentStart()
{
if (_enrollmentStartDeviceInfoCollected)
return;
_enrollmentStartDeviceInfoCollected = true;
if (!_isBootstrapMode)
{
_logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
return;
}
_logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
Task.Run(() =>
{
try
{
_deviceInfoCollector.CollectAtEnrollmentStart();
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
}
});
}
private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
{
if (_finalDeviceInfoCollected)
{
_logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
return;
}
_finalDeviceInfoCollected = true;
_logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
CollectDeviceInfoAtEnd();
}
private void SummaryTimerCallback(object state)
{
// Only emit if state-breakdown counters changed since last emission (backstop for missed events)
EmitAppTrackingSummaryIfChanged();
// Periodic state save (piggybacks on the existing 30s timer)
if (_stateDirty)
{
_stateDirty = false;
_statePersistence.Save(_stateData);
}
}
/// <summary>
/// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
/// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
/// </summary>
private void DebugStateTimerCallback(object state)
{
try
{
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
_logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
$"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
$"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
$"active: [{string.Join(", ", active)}], " +
$"failed: [{string.Join(", ", errors)}], " +
$"phase: {_lastEspPhase ?? "none"}, espExit: {_espFinalExitSeen}, desktop: {_desktopArrived}");
}
catch (Exception ex)
{
_logger.Debug($"[StateSnapshot] Error: {ex.Message}");
}
}
/// <summary>
/// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
/// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
/// </summary>
private void EmitAppTrackingSummaryIfChanged()
{
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
var hash = GetSummaryHash(states);
if (hash == _lastEmittedSummaryHash) return;
_lastEmittedSummaryHash = hash;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "app_tracking_summary",
Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
Data = states.GetSummaryData()
});
}
/// <summary>
/// Emits app_tracking_summary unconditionally (for final summary on completion).
/// </summary>
private void EmitAppTrackingSummary()
{
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
_lastEmittedSummaryHash = GetSummaryHash(states);
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "app_tracking_summary",
Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
Data = states.GetSummaryData()
});
}
private static string GetSummaryHash(AppPackageStateList states)
{
return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
$"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
$"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
$"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
}
public void Dispose()
{
Stop();
// Unsubscribe from EspAndHelloTracker events
if (_espAndHelloTracker != null)
{
_espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
_espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
_espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
_espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
}
_espFailureTimer?.Dispose();
_deviceOnlyEspTimer?.Dispose();
_waitingForHelloSafetyTimer?.Dispose();
_summaryTimer?.Dispose();
_debugStateTimer?.Dispose();
_imeLogTracker?.Dispose();
}
/// <summary>
/// Writes an enrollment complete marker to the state directory.
/// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
/// </summary>
private void WriteEnrollmentCompleteMarker()
{
try
{
var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
Directory.CreateDirectory(stateDirectory);
var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
_logger.Info($"Enrollment complete marker written: {markerPath}");
}
catch (Exception ex)
{
_logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
}
}
/// <summary>
/// Writes a comprehensive final-status.json with all app states, signals, and error summary.
/// Automatically included in diagnostics uploads for post-mortem analysis.
/// </summary>
private void WriteFinalStatus(string outcome, string source)
{
try
{
var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
Directory.CreateDirectory(stateDirectory);
var appStates = _imeLogTracker?.PackageStates;
var signalTimestamps = new Dictionary<string, string>();
if (_stateData.EspFirstSeenUtc.HasValue)
signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
if (_stateData.EspFinalExitUtc.HasValue)
signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
if (_stateData.DesktopArrivedUtc.HasValue)
signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
if (_stateData.HelloResolvedUtc.HasValue)
signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
if (_stateData.ImePatternSeenUtc.HasValue)
signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
// Build per-phase package states: snapshots from completed phases + current phase
var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
var packageStatesByPhase = new Dictionary<string, object>();
// Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
if (phaseSnapshots != null)
{
foreach (var kvp in phaseSnapshots)
packageStatesByPhase[kvp.Key] = kvp.Value;
}
// Add current phase (the phase active at completion, e.g. AccountSetup)
var currentPhaseName = _lastEspPhase ?? "Unknown";
var currentPhaseList = appStates?.ToFinalStatusList() ?? new List<Dictionary<string, object>>();
packageStatesByPhase[currentPhaseName] = currentPhaseList;
// Aggregate app summary across ALL phases (snapshots + current)
var currentPhaseTotal = appStates?.CountAll ?? 0;
var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
var currentPhaseErrors = appStates?.ErrorCount ?? 0;
var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
var allPhaseTotals = new Dictionary<string, int>();
var totalAppsAllPhases = currentPhaseTotal;
var completedAppsAllPhases = currentPhaseCompleted;
var errorCountAllPhases = currentPhaseErrors;
var deviceErrorsAllPhases = currentPhaseDeviceErrors;
var userErrorsAllPhases = currentPhaseUserErrors;
allPhaseTotals[currentPhaseName] = currentPhaseTotal;
if (phaseSnapshots != null)
{
foreach (var kvp in phaseSnapshots)
{
var phaseApps = kvp.Value;
allPhaseTotals[kvp.Key] = phaseApps.Count;
totalAppsAllPhases += phaseApps.Count;
foreach (var app in phaseApps)
{
object stateVal;
var isError = app.TryGetValue("state", out stateVal) &&
string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
var isCompleted = app.TryGetValue("state", out stateVal) &&
(stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
if (isCompleted) completedAppsAllPhases++;
if (isError)
{
errorCountAllPhases++;
object targetVal;
if (app.TryGetValue("targeted", out targetVal))
{
if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
deviceErrorsAllPhases++;
else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
userErrorsAllPhases++;
}
}
}
}
}
var status = new Dictionary<string, object>
{
{ "timestamp", DateTime.UtcNow.ToString("o") },
{ "outcome", outcome },
{ "completionSource", source },
{ "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
{ "enrollmentType", _enrollmentType ?? "unknown" },
{ "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
{ "signalsSeen", _stateData.SignalsSeen ?? new List<string>() },
{ "signalTimestamps", signalTimestamps },
{ "appSummary", new Dictionary<string, object>
{
{ "totalApps", totalAppsAllPhases },
{ "completedApps", completedAppsAllPhases },
{ "errorCount", errorCountAllPhases },
{ "deviceErrors", deviceErrorsAllPhases },
{ "userErrors", userErrorsAllPhases },
{ "appsByPhase", allPhaseTotals }
}
},
{ "packageStatesByPhase", packageStatesByPhase }
};
var json = System.Text.Json.JsonSerializer.Serialize(status,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var path = Path.Combine(stateDirectory, "final-status.json");
File.WriteAllText(path, json);
_logger.Info($"Final status written: {path}");
}
catch (Exception ex)
{
_logger.Warning($"Failed to write final status: {ex.Message}");
}
}
}
}
@@ -0,0 +1,467 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
/// </summary>
public partial class EnrollmentTracker
{
private void HandleEspPhaseChanged(string phase)
{
// WDP (v2) has no ESP - skip ESP phase handling entirely
if (_enrollmentType == "v2")
{
_logger.Debug($"EnrollmentTracker: skipping ESP phase event in WDP enrollment (phase: {phase})");
return;
}
// Only emit event if the phase has actually changed
if (string.Equals(phase, _lastEspPhase, StringComparison.OrdinalIgnoreCase))
{
_logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
return;
}
_logger.Info($"EnrollmentTracker: ESP phase changed from '{_lastEspPhase ?? "null"}' to '{phase}'");
_lastEspPhase = phase;
_hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
_espEverSeen = true;
_stateData.EspEverSeen = true;
_stateData.LastEspPhase = phase;
if (_stateData.EspFirstSeenUtc == null)
_stateData.EspFirstSeenUtc = DateTime.UtcNow;
RecordSignal($"esp_phase_{phase}");
// ESP phase change means ESP is progressing — cancel any pending failure grace period
CancelPendingEspFailure();
// Cancel device-only ESP timer if AccountSetup phase detected
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
if (_deviceOnlyEspTimer != null)
{
_logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
_deviceOnlyEspTimer.Dispose();
_deviceOnlyEspTimer = null;
}
}
// Map ESP phase to EnrollmentPhase (phase change events)
var enrollmentPhase = EnrollmentPhase.DeviceSetup;
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
enrollmentPhase = EnrollmentPhase.AccountSetup;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = enrollmentPhase,
Message = $"ESP phase: {phase}",
Data = new Dictionary<string, object> { { "espPhase", phase } }
});
// Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
// that may have been empty at startup (bootstrap / pre-enrollment scenario).
CollectDeviceInfoAtEnrollmentStart();
// Start summary timer when we detect ESP phase
if (!_summaryTimerActive)
{
_summaryTimerActive = true;
_summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}
if (!_debugStateTimerActive)
{
_debugStateTimerActive = true;
_debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
}
private void HandleImeAgentVersion(string version)
{
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "ime_agent_version",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"IME Agent version: {version}",
Data = new Dictionary<string, object> { { "agentVersion", version } }
});
}
private void HandleImeStarted()
{
_logger.Info("EnrollmentTracker: IME started event");
}
private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
{
// Auto-switch to app installation phase when first app activity detected
// If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
// If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
if (!_hasAutoSwitchedToAppsPhase &&
(newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
oldState < AppInstallationState.Downloading)
{
if (_lastEspPhase != null)
{
if (string.Equals(_lastEspPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
{
// Switch from DeviceSetup to AppsDevice
_logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
_hasAutoSwitchedToAppsPhase = true;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.AppsDevice,
Message = "ESP phase: AppsDevice (auto-detected from app activity)",
Data = new Dictionary<string, object> { { "espPhase", "AppsDevice" }, { "autoDetected", true } }
});
}
else if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
// Switch from AccountSetup to AppsUser
_logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
_hasAutoSwitchedToAppsPhase = true;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.AppsUser,
Message = "ESP phase: AppsUser (auto-detected from app activity)",
Data = new Dictionary<string, object> { { "espPhase", "AppsUser" }, { "autoDetected", true } }
});
}
}
}
// Only emit strategic events for significant state transitions
string eventType;
var severity = EventSeverity.Info;
var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
switch (newState)
{
case AppInstallationState.Downloading:
// Emit strategic event once when download starts
if (oldState < AppInstallationState.Downloading)
{
eventType = "app_download_started";
}
else
{
// Emit debug event for download progress updates
// Skip if no real download data (bytesTotal too small or zero)
if (app.BytesTotal > 1024) // At least 1 KB to be a real download
{
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
Data = app.ToEventData()
});
}
return; // Skip main event emission below
}
break;
case AppInstallationState.Installing:
// Only emit the strategic event once when install actually starts.
// Progress-only updates (oldState == Installing, just progress/bytes changed)
// are skipped — same pattern as Downloading above.
if (oldState == AppInstallationState.Installing)
return;
eventType = "app_install_started";
break;
case AppInstallationState.Installed:
eventType = "app_install_completed";
// Emit download_progress event for download manager (shows as completed)
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: completed",
Data = new Dictionary<string, object>(app.ToEventData())
{
["status"] = "completed"
}
});
break;
case AppInstallationState.Skipped:
eventType = "app_install_skipped";
break;
case AppInstallationState.Error:
eventType = "app_install_failed";
severity = EventSeverity.Error;
// Emit download_progress event for download manager (shows as failed)
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: failed",
Data = new Dictionary<string, object>(app.ToEventData())
{
["status"] = "failed"
}
});
break;
case AppInstallationState.Postponed:
eventType = "app_install_failed";
severity = EventSeverity.Warning;
break;
default:
return; // Don't emit for Unknown, NotInstalled, InProgress
}
// Build a descriptive message: include error detail if available
var message = $"{app.Name ?? app.Id}: {newState}";
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = "ImeLogTracker",
Phase = phase,
Message = message,
Data = app.ToEventData()
});
// Emit summary immediately if state-breakdown counters changed (instant UI updates)
EmitAppTrackingSummaryIfChanged();
}
private void HandlePoliciesDiscovered(string policiesJson)
{
_logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
}
private void HandleAllAppsCompleted()
{
_logger.Info("EnrollmentTracker: all apps completed");
// Stop timers
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
// Emit final summary
EmitAppTrackingSummary();
// Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
// (ESP exit or Hello wizard start) for more robust detection.
// We no longer automatically transition here when apps complete.
if (_lastEspPhase != null)
{
_logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'");
_logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
}
}
private void HandleDoTelemetryReceived(AppPackageState app)
{
_logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
var phase = EnrollmentPhase.Unknown;
// Dedicated do_telemetry event for backend aggregation
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "do_telemetry",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
Data = app.ToEventData()
});
// Also emit download_progress so the UI picks up DO stats
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: DO telemetry received",
Data = app.ToEventData()
});
}
private void HandleScriptCompleted(ScriptExecutionState script)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
|| (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
var eventType = isSuccess
? Constants.EventTypes.ScriptCompleted
: Constants.EventTypes.ScriptFailed;
var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
// Build human-readable message
string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
string message;
if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
? "Compliant" : "Non-compliant";
message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
}
else
{
message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
}
// Add stderr hint to message if present
if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
message += " - stderr present";
var data = new Dictionary<string, object>
{
["policyId"] = script.PolicyId,
["scriptType"] = script.ScriptType ?? "platform",
["exitCode"] = script.ExitCode ?? -1
};
if (!string.IsNullOrEmpty(script.RunContext))
data["runContext"] = script.RunContext;
if (!string.IsNullOrEmpty(script.Result))
data["result"] = script.Result;
if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
data["stdout"] = script.Stdout;
if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
data["stderr"] = script.Stderr;
if (!string.IsNullOrEmpty(script.ComplianceResult))
data["complianceResult"] = script.ComplianceResult;
if (!string.IsNullOrEmpty(script.ScriptPart))
data["scriptPart"] = script.ScriptPart;
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = Constants.EventSources.IME,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
_logger.Info($"EnrollmentTracker: {message}");
}
private void HandleUserSessionCompleted()
{
_logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
_stateData.ImePatternSeenUtc = DateTime.UtcNow;
RecordSignal("ime_pattern");
// User session completed successfully — cancel any pending ESP failure
CancelPendingEspFailure();
// Stop timers if running
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
// Check if Windows Hello is configured but not yet completed
if (_espAndHelloTracker != null)
{
bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
if (helloPolicyConfigured && !helloCompleted)
{
// Hello is configured but not finished yet - DO NOT mark enrollment as complete
_logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
_logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "waiting_for_hello",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.FinalizingSetup,
Message = "User apps completed - waiting for Windows Hello provisioning to finish"
});
// Set flag so we know we're waiting
_isWaitingForHello = true;
_stateData.IsWaitingForHello = true;
_stateDirty = true;
_statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
// Defense-in-depth: Ensure Hello wait timer is running.
// StartHelloWaitTimer() has internal guards (returns if timer already running,
// if Hello already completed, or if wizard already started). Safe to call multiple times.
_espAndHelloTracker?.StartHelloWaitTimer();
// Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
// force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
_waitingForHelloSafetyTimer?.Dispose();
_waitingForHelloSafetyTimer = new Timer(
OnWaitingForHelloSafetyTimeout,
null,
TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
return;
}
}
TryEmitEnrollmentComplete("ime_pattern");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Partial: Event handlers for pattern matches (app state, scripts, ESP phases),
/// telemetry processing, and utility methods.
/// </summary>
public partial class ImeLogTracker
{
private void HandleDoTelemetry(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Extract app GUID from FileId
string fileId = root.TryGetProperty("FileId", out var fileIdProp)
? fileIdProp.GetString() : null;
if (string.IsNullOrEmpty(fileId))
{
_logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
return;
}
string appId = ExtractAppIdFromDoFileId(fileId);
if (string.IsNullOrEmpty(appId))
{
appId = _packageStates.CurrentPackageId;
_logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
}
if (string.IsNullOrEmpty(appId)) return;
var pkg = _packageStates.GetPackage(appId);
if (pkg == null)
{
_logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
return;
}
// Extract all DO fields
long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
downloadMode, downloadDuration, bytesFromHttp);
_logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
OnDoTelemetryReceived?.Invoke(pkg);
_stateDirty = true;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
}
}
/// <summary>
/// Extracts app GUID from DO FileId string.
/// Format: ...intunewin-bin_{appGuid}_{number}
/// Falls back to trying the second-to-last GUID-like segment.
/// </summary>
private static string ExtractAppIdFromDoFileId(string fileId)
{
// Primary: look for "intunewin-bin_" marker
const string marker = "intunewin-bin_";
var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var afterMarker = fileId.Substring(idx + marker.Length);
if (afterMarker.Length >= 36)
{
var candidate = afterMarker.Substring(0, 36);
if (Guid.TryParse(candidate, out var guid))
return guid.ToString().ToLowerInvariant();
}
}
// Fallback: split by underscore and find GUIDs, take the second-to-last one
var segments = fileId.Split('_');
for (int i = segments.Length - 2; i >= 0; i--)
{
if (Guid.TryParse(segments[i], out var guid))
return guid.ToString().ToLowerInvariant();
}
return null;
}
// -----------------------------------------------------------------------
// PowerShell script tracking handlers
// -----------------------------------------------------------------------
private ScriptExecutionState GetCurrentScript(Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
? _currentRemediationScript
: GetCurrentPlatformScript();
}
private ScriptExecutionState GetCurrentPlatformScript()
{
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
_pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
return state;
return null;
}
private void HandleScriptStarted(Match match, Dictionary<string, string> parameters)
{
var id = match.Groups["id"]?.Value;
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null;
if (string.IsNullOrEmpty(id))
{
_logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping");
return;
}
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
// Flush any pending remediation script (shouldn't happen normally, but defensive)
if (_currentRemediationScript != null)
{
_logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
EmitScriptEvent(_currentRemediationScript);
}
_currentRemediationScript = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "remediation",
ScriptPart = "detection" // detection runs first; updated if remediation runs
};
_logger.Debug($"ImeLogTracker: remediation script started: {id}");
}
else
{
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
if (!_pendingPlatformScripts.ContainsKey(id))
{
_pendingPlatformScripts[id] = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
_lastPlatformScriptPolicyId = id;
_logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
}
}
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
{
var context = match.Groups["context"]?.Value;
if (string.IsNullOrEmpty(context)) return;
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
var script = GetCurrentScript(parameters);
if (script != null)
{
script.RunContext = runContext;
_logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}");
}
}
private void HandleScriptExitCode(Match match, Dictionary<string, string> parameters)
{
var exitCodeStr = match.Groups["exitCode"]?.Value;
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
var script = GetCurrentScript(parameters);
if (script != null)
{
script.ExitCode = exitCode;
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
}
}
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
{
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
var script = GetCurrentScript(parameters);
if (script == null) return;
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
var output = match.Groups["output"]?.Value;
var error = match.Groups["error"]?.Value;
if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
{
// Combined pattern (write output done. output = ..., error = ...)
script.Stdout = TruncateOutput(output.Trim());
script.Stderr = TruncateOutput(error.Trim());
}
else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
{
script.Stderr = TruncateOutput(output?.Trim());
}
else
{
script.Stdout = TruncateOutput(output?.Trim());
}
_logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})");
}
private void HandleScriptCompleted(Match match, Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
if (_currentRemediationScript == null) return;
var compliance = match.Groups["compliance"]?.Value;
var id = match.Groups["id"]?.Value;
_currentRemediationScript.ComplianceResult = compliance;
if (!string.IsNullOrEmpty(part))
_currentRemediationScript.ScriptPart = part;
if (!string.IsNullOrEmpty(id))
_currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
_logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
$"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
EmitScriptEvent(_currentRemediationScript);
_currentRemediationScript = null;
}
else
{
// Platform script: final result from IntuneManagementExtension.log
var id = match.Groups["id"]?.Value;
var result = match.Groups["result"]?.Value;
if (string.IsNullOrEmpty(id)) return;
// Merge with pending AgentExecutor data if available
if (!_pendingPlatformScripts.TryGetValue(id, out var script))
{
script = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
script.Result = result;
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
EmitScriptEvent(script);
_pendingPlatformScripts.Remove(id);
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
_lastPlatformScriptPolicyId = null;
}
}
private void EmitScriptEvent(ScriptExecutionState script)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
OnScriptCompleted?.Invoke(script);
}
private static string TruncateOutput(string output)
{
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
return output;
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
}
// -----------------------------------------------------------------------
// IME lifecycle handlers
// -----------------------------------------------------------------------
private void HandleImeStarted()
{
_logger.Info("ImeLogTracker: IME Agent Started detected");
// Log any currently active app — it will be re-evaluated by new log entries after IME restart.
// We intentionally do NOT mark it as Installed here because IME may retry the app.
if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
{
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg?.IsActive == true)
_logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart");
}
OnImeStarted?.Invoke();
}
private void HandleImeShutdown()
{
_logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed");
foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList())
{
_logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})");
UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed);
}
}
private string _lastEspPhaseDetected;
private void HandleEspPhaseDetected(string espPhaseString)
{
// Validate phase and get its ordinal for forward-only enforcement
int phaseOrd;
if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd))
return; // Not a recognized ESP phase
bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current"
// Forward-only phase progression: reject backward transitions.
// During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup"
// which would corrupt tracking if we allowed it to trigger a phase change.
if (phaseOrd < _currentPhaseOrder)
{
_logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
return;
}
// If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup),
// move ALL known app IDs into the ignore list so they won't emit events in the new phase.
// We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from
// any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp,
// esptrackstatus etc. that never entered _packageStates are also silenced.
if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase))
{
if (_lastEspPhaseDetected != null) // Not the first phase detection
{
var ignoredFromStates = 0;
var ignoredFromSeen = 0;
foreach (var pkg in _packageStates)
{
if (_packageStates.AddToIgnoreList(pkg.Id))
ignoredFromStates++;
}
foreach (var appId in _seenAppIds)
{
if (_packageStates.AddToIgnoreList(appId))
ignoredFromSeen++;
}
_logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " +
$"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " +
$"(total ignore list: {_packageStates.IgnoreList.Count})");
// Snapshot package states from the completed phase before clearing
if (_packageStates.CountAll > 0)
{
_phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList();
_logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
}
_packageStates.Clear();
_packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference
_seenAppIds.Clear();
_allAppsCompletedFired = false;
}
_lastEspPhaseDetected = espPhaseString;
_currentPhaseOrder = phaseOrd;
}
_logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
ActivatePatterns(logPhaseIsCurrentPhase);
OnEspPhaseChanged?.Invoke(espPhaseString);
}
private void HandleEspTrackStatus(Match match)
{
var from = match.Groups["from"]?.Value;
var to = match.Groups["to"]?.Value;
var id = match.Groups["id"]?.Value;
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return;
var pkg = _packageStates.GetPackage(id);
var label = pkg?.Name ?? id;
if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress");
_packageStates.SetCurrent(id);
UpdateStateWithCallback(id, AppInstallationState.Installing);
}
else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed");
UpdateStateWithCallback(id, AppInstallationState.Installed);
}
else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error");
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}");
}
}
private void HandlePoliciesDiscovered(string policiesJson)
{
try
{
var policies = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(policiesJson);
if (policies != null)
{
// Ignore user-targeted packages during device setup (we only track device phase in general)
_packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
OnPoliciesDiscovered?.Invoke(policiesJson);
}
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}");
}
}
private void HandleCancelStuckAndSetCurrent(string newId)
{
if (string.IsNullOrEmpty(newId)) return;
// If current app is stuck in an active state, mark it as skipped
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing ||
currentPkg.InstallationState == AppInstallationState.InProgress ||
currentPkg.InstallationState == AppInstallationState.Downloading))
{
_logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}");
UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
}
_packageStates.SetCurrent(newId);
}
private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
// Set error context before state change so it's available in ToEventData()
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId))
pkg.SetErrorContext(errorPatternId, errorDetail);
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateState(id, newState, progressPercent);
if (changed)
{
_logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
OnAppStateChanged?.Invoke(pkg, oldState, newState);
// Check if all apps are now completed - only fire once
if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
{
_allAppsCompletedFired = true;
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
OnAllAppsCompleted?.Invoke();
}
}
}
private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes);
if (changed)
{
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
}
}
private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false)
{
if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase)
return;
var patterns = new List<CompiledPattern>(_patternsAlways);
if (logPhaseIsCurrentPhase)
{
patterns.AddRange(_patternsCurrentPhase);
_lastLogTimestamp = DateTime.MinValue; // Reset simulation delay
}
else
{
patterns.AddRange(_patternsOtherPhases);
}
_activePatterns = patterns;
_packageStates.SetCurrent(""); // Reset current package
_logPhaseIsCurrentPhase = logPhaseIsCurrentPhase;
}
private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token)
{
if (!SimulationMode || logTimestamp == DateTime.MinValue)
return;
if (_lastLogTimestamp != DateTime.MinValue)
{
var timeSpan = logTimestamp - _lastLogTimestamp;
if (timeSpan.TotalMilliseconds > 0)
{
var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor);
delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds
if (delayMs > 0)
await Task.Delay(delayMs, token);
}
}
_lastLogTimestamp = logTimestamp;
}
public void Dispose()
{
Stop();
_cts?.Dispose();
}
private void WriteMatchLog(string sourceFile, string rawLine, string patternId)
{
if (string.IsNullOrEmpty(_matchLogPath)) return;
try
{
var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}";
lock (_matchLogLock)
{
File.AppendAllText(_matchLogPath, entry + Environment.NewLine);
}
}
catch { }
}
/// <summary>
/// A compiled regex pattern with its action and parameters
/// </summary>
private class CompiledPattern
{
public string PatternId { get; set; }
public Regex Regex { get; set; }
public string Action { get; set; }
public Dictionary<string, string> Parameters { get; set; }
}
}
}
@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Partial: Log file polling and pattern matching logic.
/// </summary>
public partial class ImeLogTracker
{
private async Task CheckLogFilesAsync(CancellationToken token)
{
if (!Directory.Exists(_logFolder))
return;
// Get all matching log files, sorted by name (archived files come before current)
var files = new List<string>();
foreach (var pattern in LogFilePatterns)
{
try
{
files.AddRange(Directory.GetFiles(_logFolder, pattern));
}
catch (DirectoryNotFoundException) { }
}
files.Sort(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in files)
{
if (token.IsCancellationRequested) break;
try
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists) continue;
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
if (startPos >= fileInfo.Length) continue;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPos, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
{
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (token.IsCancellationRequested) break;
// Parse CMTrace format to get the message content
CmTraceLogEntry entry;
string messageToMatch;
if (CmTraceLogParser.TryParseLine(line, out entry))
{
messageToMatch = entry.Message;
}
else
{
// Non-CMTrace line - match raw
messageToMatch = line;
entry = null;
}
if (string.IsNullOrEmpty(messageToMatch)) continue;
// Simulation mode delay
if (SimulationMode && entry != null)
{
await ApplySimulationDelay(entry.Timestamp, token);
}
// Match against active patterns
foreach (var pattern in _activePatterns)
{
try
{
var match = pattern.Regex.Match(messageToMatch);
if (match.Success)
{
WriteMatchLog(filePath, line, pattern.PatternId);
HandlePatternMatch(pattern, match, messageToMatch);
}
}
catch (RegexMatchTimeoutException)
{
// Skip this pattern match to prevent ReDoS
}
}
}
}
_positionTracker.SetPosition(filePath, stream.Position);
_stateDirty = true;
}
}
catch (FileNotFoundException) { }
catch (IOException ex)
{
_logger.Debug($"ImeLogTracker: IO error reading {Path.GetFileName(filePath)}: {ex.Message}");
}
}
}
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message)
{
try
{
var id = match.Groups["id"]?.Value;
var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") &&
pattern.Parameters["useCurrentApp"] == "true";
if (useCurrentApp && string.IsNullOrEmpty(id))
id = _packageStates.CurrentPackageId;
// Track every app ID seen during the current phase for comprehensive ignore on phase change
if (!string.IsNullOrEmpty(id))
_seenAppIds.Add(id);
switch (pattern.Action?.ToLower())
{
case "imestarted":
HandleImeStarted();
break;
case "imeshutdown":
HandleImeShutdown();
break;
case "imesessionchange":
_logger.Debug($"IME session change: {match.Groups["change"]?.Value}");
break;
case "espphasedetected":
var phase = match.Groups["espPhase"]?.Value;
if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase"))
phase = pattern.Parameters["phase"];
if (!string.IsNullOrEmpty(phase))
HandleEspPhaseDetected(phase);
break;
case "setcurrentapp":
if (!string.IsNullOrEmpty(id))
_packageStates.SetCurrent(id);
break;
case "imeagentversion":
var version = match.Groups["agentVersion"]?.Value;
if (!string.IsNullOrEmpty(version))
OnImeAgentVersion?.Invoke(version);
break;
case "imeimpersonation":
_logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
break;
case "enrollmentcompleted":
_logger.Info("ImeLogTracker: User session completed detected");
OnUserSessionCompleted?.Invoke();
break;
case "updatestateinstalled":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installed);
break;
case "updatestatedownloading":
if (!string.IsNullOrEmpty(id))
{
var bytes = match.Groups["bytes"]?.Value;
var ofbytes = match.Groups["ofbytes"]?.Value;
if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes))
UpdateDownloadingWithCallback(id, bytes, ofbytes);
else
UpdateStateWithCallback(id, AppInstallationState.Downloading);
}
break;
case "updatestateinstalling":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installing);
break;
case "updatestateskipped":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Skipped);
break;
case "updatestateerror":
if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true")
{
// Only apply error if the "to" value is "Error"
var toValue = match.Groups["to"]?.Value;
if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
}
else if (!string.IsNullOrEmpty(id))
{
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
}
break;
case "updatestatepostponed":
if (!string.IsNullOrEmpty(id))
{
// Only postpone if not already in a terminal state
var pkg = _packageStates.GetPackage(id);
if (pkg != null && pkg.InstallationState != AppInstallationState.Installed &&
pkg.InstallationState != AppInstallationState.Error)
{
UpdateStateWithCallback(id, AppInstallationState.Postponed);
}
}
break;
case "esptrackstatus":
HandleEspTrackStatus(match);
break;
case "policiesdiscovered":
var policiesJson = match.Groups["policies"]?.Value;
if (!string.IsNullOrEmpty(policiesJson))
HandlePoliciesDiscovered(policiesJson);
break;
case "ignorecompletedapp":
_packageStates.AddToIgnoreList(_packageStates.CurrentPackageId);
break;
case "updatename":
var name = match.Groups["name"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
_packageStates.UpdateName(id, name);
break;
case "updatewin32appstate":
var state = match.Groups["state"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state))
_packageStates.UpdateStateFromWin32AppState(id, state);
break;
case "cancelstuckandsetcurrent":
HandleCancelStuckAndSetCurrent(id);
break;
case "updatedotelemetry":
var doTelJson = match.Groups["doTelJson"]?.Value;
if (!string.IsNullOrEmpty(doTelJson))
HandleDoTelemetry(doTelJson);
break;
// PowerShell script tracking actions
case "scriptstarted":
HandleScriptStarted(match, pattern.Parameters);
break;
case "scriptcontext":
HandleScriptContext(match, pattern.Parameters);
break;
case "scriptexitcode":
HandleScriptExitCode(match, pattern.Parameters);
break;
case "scriptoutput":
HandleScriptOutput(match, pattern.Parameters);
break;
case "scriptcompleted":
HandleScriptCompleted(match, pattern.Parameters);
break;
default:
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
break;
}
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}");
}
}
/// <summary>
/// Parses [DO TEL] JSON and links it to the correct app via FileId.
/// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
/// </summary>
}
}
@@ -18,12 +18,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
/// <summary>
/// Parses IME (Intune Management Extension) log files using regex patterns from the backend.
/// Tracks app installation state transitions and emits strategic events.
/// Adapted from EspOverlay's ImeLogFileParser + RjLogFileParser.
/// Split into partial classes: Core, LogProcessing, Handlers.
///
/// Key design: Regex patterns are NOT hardcoded - they come from backend config via ImeLogPattern list.
/// This allows updating patterns without agent rebuild when Microsoft changes IME log output.
/// </summary>
public class ImeLogTracker : IDisposable
public partial class ImeLogTracker : IDisposable
{
private readonly string _logFolder;
private readonly AgentLogger _logger;
@@ -414,840 +414,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
#endregion
private async Task CheckLogFilesAsync(CancellationToken token)
{
if (!Directory.Exists(_logFolder))
return;
// Get all matching log files, sorted by name (archived files come before current)
var files = new List<string>();
foreach (var pattern in LogFilePatterns)
{
try
{
files.AddRange(Directory.GetFiles(_logFolder, pattern));
}
catch (DirectoryNotFoundException) { }
}
files.Sort(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in files)
{
if (token.IsCancellationRequested) break;
try
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists) continue;
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
if (startPos >= fileInfo.Length) continue;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPos, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
{
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (token.IsCancellationRequested) break;
// Parse CMTrace format to get the message content
CmTraceLogEntry entry;
string messageToMatch;
if (CmTraceLogParser.TryParseLine(line, out entry))
{
messageToMatch = entry.Message;
}
else
{
// Non-CMTrace line - match raw
messageToMatch = line;
entry = null;
}
if (string.IsNullOrEmpty(messageToMatch)) continue;
// Simulation mode delay
if (SimulationMode && entry != null)
{
await ApplySimulationDelay(entry.Timestamp, token);
}
// Match against active patterns
foreach (var pattern in _activePatterns)
{
try
{
var match = pattern.Regex.Match(messageToMatch);
if (match.Success)
{
WriteMatchLog(filePath, line, pattern.PatternId);
HandlePatternMatch(pattern, match, messageToMatch);
}
}
catch (RegexMatchTimeoutException)
{
// Skip this pattern match to prevent ReDoS
}
}
}
}
_positionTracker.SetPosition(filePath, stream.Position);
_stateDirty = true;
}
}
catch (FileNotFoundException) { }
catch (IOException ex)
{
_logger.Debug($"ImeLogTracker: IO error reading {Path.GetFileName(filePath)}: {ex.Message}");
}
}
}
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message)
{
try
{
var id = match.Groups["id"]?.Value;
var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") &&
pattern.Parameters["useCurrentApp"] == "true";
if (useCurrentApp && string.IsNullOrEmpty(id))
id = _packageStates.CurrentPackageId;
// Track every app ID seen during the current phase for comprehensive ignore on phase change
if (!string.IsNullOrEmpty(id))
_seenAppIds.Add(id);
switch (pattern.Action?.ToLower())
{
case "imestarted":
HandleImeStarted();
break;
case "imeshutdown":
HandleImeShutdown();
break;
case "imesessionchange":
_logger.Debug($"IME session change: {match.Groups["change"]?.Value}");
break;
case "espphasedetected":
var phase = match.Groups["espPhase"]?.Value;
if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase"))
phase = pattern.Parameters["phase"];
if (!string.IsNullOrEmpty(phase))
HandleEspPhaseDetected(phase);
break;
case "setcurrentapp":
if (!string.IsNullOrEmpty(id))
_packageStates.SetCurrent(id);
break;
case "imeagentversion":
var version = match.Groups["agentVersion"]?.Value;
if (!string.IsNullOrEmpty(version))
OnImeAgentVersion?.Invoke(version);
break;
case "imeimpersonation":
_logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
break;
case "enrollmentcompleted":
_logger.Info("ImeLogTracker: User session completed detected");
OnUserSessionCompleted?.Invoke();
break;
case "updatestateinstalled":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installed);
break;
case "updatestatedownloading":
if (!string.IsNullOrEmpty(id))
{
var bytes = match.Groups["bytes"]?.Value;
var ofbytes = match.Groups["ofbytes"]?.Value;
if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes))
UpdateDownloadingWithCallback(id, bytes, ofbytes);
else
UpdateStateWithCallback(id, AppInstallationState.Downloading);
}
break;
case "updatestateinstalling":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installing);
break;
case "updatestateskipped":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Skipped);
break;
case "updatestateerror":
if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true")
{
// Only apply error if the "to" value is "Error"
var toValue = match.Groups["to"]?.Value;
if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
}
else if (!string.IsNullOrEmpty(id))
{
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
}
break;
case "updatestatepostponed":
if (!string.IsNullOrEmpty(id))
{
// Only postpone if not already in a terminal state
var pkg = _packageStates.GetPackage(id);
if (pkg != null && pkg.InstallationState != AppInstallationState.Installed &&
pkg.InstallationState != AppInstallationState.Error)
{
UpdateStateWithCallback(id, AppInstallationState.Postponed);
}
}
break;
case "esptrackstatus":
HandleEspTrackStatus(match);
break;
case "policiesdiscovered":
var policiesJson = match.Groups["policies"]?.Value;
if (!string.IsNullOrEmpty(policiesJson))
HandlePoliciesDiscovered(policiesJson);
break;
case "ignorecompletedapp":
_packageStates.AddToIgnoreList(_packageStates.CurrentPackageId);
break;
case "updatename":
var name = match.Groups["name"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
_packageStates.UpdateName(id, name);
break;
case "updatewin32appstate":
var state = match.Groups["state"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state))
_packageStates.UpdateStateFromWin32AppState(id, state);
break;
case "cancelstuckandsetcurrent":
HandleCancelStuckAndSetCurrent(id);
break;
case "updatedotelemetry":
var doTelJson = match.Groups["doTelJson"]?.Value;
if (!string.IsNullOrEmpty(doTelJson))
HandleDoTelemetry(doTelJson);
break;
// PowerShell script tracking actions
case "scriptstarted":
HandleScriptStarted(match, pattern.Parameters);
break;
case "scriptcontext":
HandleScriptContext(match, pattern.Parameters);
break;
case "scriptexitcode":
HandleScriptExitCode(match, pattern.Parameters);
break;
case "scriptoutput":
HandleScriptOutput(match, pattern.Parameters);
break;
case "scriptcompleted":
HandleScriptCompleted(match, pattern.Parameters);
break;
default:
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
break;
}
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}");
}
}
/// <summary>
/// Parses [DO TEL] JSON and links it to the correct app via FileId.
/// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
/// </summary>
private void HandleDoTelemetry(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Extract app GUID from FileId
string fileId = root.TryGetProperty("FileId", out var fileIdProp)
? fileIdProp.GetString() : null;
if (string.IsNullOrEmpty(fileId))
{
_logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
return;
}
string appId = ExtractAppIdFromDoFileId(fileId);
if (string.IsNullOrEmpty(appId))
{
appId = _packageStates.CurrentPackageId;
_logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
}
if (string.IsNullOrEmpty(appId)) return;
var pkg = _packageStates.GetPackage(appId);
if (pkg == null)
{
_logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
return;
}
// Extract all DO fields
long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
downloadMode, downloadDuration, bytesFromHttp);
_logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
OnDoTelemetryReceived?.Invoke(pkg);
_stateDirty = true;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
}
}
/// <summary>
/// Extracts app GUID from DO FileId string.
/// Format: ...intunewin-bin_{appGuid}_{number}
/// Falls back to trying the second-to-last GUID-like segment.
/// </summary>
private static string ExtractAppIdFromDoFileId(string fileId)
{
// Primary: look for "intunewin-bin_" marker
const string marker = "intunewin-bin_";
var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var afterMarker = fileId.Substring(idx + marker.Length);
if (afterMarker.Length >= 36)
{
var candidate = afterMarker.Substring(0, 36);
if (Guid.TryParse(candidate, out var guid))
return guid.ToString().ToLowerInvariant();
}
}
// Fallback: split by underscore and find GUIDs, take the second-to-last one
var segments = fileId.Split('_');
for (int i = segments.Length - 2; i >= 0; i--)
{
if (Guid.TryParse(segments[i], out var guid))
return guid.ToString().ToLowerInvariant();
}
return null;
}
// -----------------------------------------------------------------------
// PowerShell script tracking handlers
// -----------------------------------------------------------------------
private ScriptExecutionState GetCurrentScript(Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
? _currentRemediationScript
: GetCurrentPlatformScript();
}
private ScriptExecutionState GetCurrentPlatformScript()
{
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
_pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
return state;
return null;
}
private void HandleScriptStarted(Match match, Dictionary<string, string> parameters)
{
var id = match.Groups["id"]?.Value;
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null;
if (string.IsNullOrEmpty(id))
{
_logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping");
return;
}
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
// Flush any pending remediation script (shouldn't happen normally, but defensive)
if (_currentRemediationScript != null)
{
_logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
EmitScriptEvent(_currentRemediationScript);
}
_currentRemediationScript = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "remediation",
ScriptPart = "detection" // detection runs first; updated if remediation runs
};
_logger.Debug($"ImeLogTracker: remediation script started: {id}");
}
else
{
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
if (!_pendingPlatformScripts.ContainsKey(id))
{
_pendingPlatformScripts[id] = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
_lastPlatformScriptPolicyId = id;
_logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
}
}
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
{
var context = match.Groups["context"]?.Value;
if (string.IsNullOrEmpty(context)) return;
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
var script = GetCurrentScript(parameters);
if (script != null)
{
script.RunContext = runContext;
_logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}");
}
}
private void HandleScriptExitCode(Match match, Dictionary<string, string> parameters)
{
var exitCodeStr = match.Groups["exitCode"]?.Value;
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
var script = GetCurrentScript(parameters);
if (script != null)
{
script.ExitCode = exitCode;
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
}
}
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
{
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
var script = GetCurrentScript(parameters);
if (script == null) return;
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
var output = match.Groups["output"]?.Value;
var error = match.Groups["error"]?.Value;
if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
{
// Combined pattern (write output done. output = ..., error = ...)
script.Stdout = TruncateOutput(output.Trim());
script.Stderr = TruncateOutput(error.Trim());
}
else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
{
script.Stderr = TruncateOutput(output?.Trim());
}
else
{
script.Stdout = TruncateOutput(output?.Trim());
}
_logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})");
}
private void HandleScriptCompleted(Match match, Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
if (_currentRemediationScript == null) return;
var compliance = match.Groups["compliance"]?.Value;
var id = match.Groups["id"]?.Value;
_currentRemediationScript.ComplianceResult = compliance;
if (!string.IsNullOrEmpty(part))
_currentRemediationScript.ScriptPart = part;
if (!string.IsNullOrEmpty(id))
_currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
_logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
$"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
EmitScriptEvent(_currentRemediationScript);
_currentRemediationScript = null;
}
else
{
// Platform script: final result from IntuneManagementExtension.log
var id = match.Groups["id"]?.Value;
var result = match.Groups["result"]?.Value;
if (string.IsNullOrEmpty(id)) return;
// Merge with pending AgentExecutor data if available
if (!_pendingPlatformScripts.TryGetValue(id, out var script))
{
script = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
script.Result = result;
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
EmitScriptEvent(script);
_pendingPlatformScripts.Remove(id);
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
_lastPlatformScriptPolicyId = null;
}
}
private void EmitScriptEvent(ScriptExecutionState script)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
OnScriptCompleted?.Invoke(script);
}
private static string TruncateOutput(string output)
{
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
return output;
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
}
// -----------------------------------------------------------------------
// IME lifecycle handlers
// -----------------------------------------------------------------------
private void HandleImeStarted()
{
_logger.Info("ImeLogTracker: IME Agent Started detected");
// Log any currently active app — it will be re-evaluated by new log entries after IME restart.
// We intentionally do NOT mark it as Installed here because IME may retry the app.
if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
{
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg?.IsActive == true)
_logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart");
}
OnImeStarted?.Invoke();
}
private void HandleImeShutdown()
{
_logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed");
foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList())
{
_logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})");
UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed);
}
}
private string _lastEspPhaseDetected;
private void HandleEspPhaseDetected(string espPhaseString)
{
// Validate phase and get its ordinal for forward-only enforcement
int phaseOrd;
if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd))
return; // Not a recognized ESP phase
bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current"
// Forward-only phase progression: reject backward transitions.
// During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup"
// which would corrupt tracking if we allowed it to trigger a phase change.
if (phaseOrd < _currentPhaseOrder)
{
_logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
return;
}
// If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup),
// move ALL known app IDs into the ignore list so they won't emit events in the new phase.
// We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from
// any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp,
// esptrackstatus etc. that never entered _packageStates are also silenced.
if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase))
{
if (_lastEspPhaseDetected != null) // Not the first phase detection
{
var ignoredFromStates = 0;
var ignoredFromSeen = 0;
foreach (var pkg in _packageStates)
{
if (_packageStates.AddToIgnoreList(pkg.Id))
ignoredFromStates++;
}
foreach (var appId in _seenAppIds)
{
if (_packageStates.AddToIgnoreList(appId))
ignoredFromSeen++;
}
_logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " +
$"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " +
$"(total ignore list: {_packageStates.IgnoreList.Count})");
// Snapshot package states from the completed phase before clearing
if (_packageStates.CountAll > 0)
{
_phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList();
_logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
}
_packageStates.Clear();
_packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference
_seenAppIds.Clear();
_allAppsCompletedFired = false;
}
_lastEspPhaseDetected = espPhaseString;
_currentPhaseOrder = phaseOrd;
}
_logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
ActivatePatterns(logPhaseIsCurrentPhase);
OnEspPhaseChanged?.Invoke(espPhaseString);
}
private void HandleEspTrackStatus(Match match)
{
var from = match.Groups["from"]?.Value;
var to = match.Groups["to"]?.Value;
var id = match.Groups["id"]?.Value;
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return;
var pkg = _packageStates.GetPackage(id);
var label = pkg?.Name ?? id;
if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress");
_packageStates.SetCurrent(id);
UpdateStateWithCallback(id, AppInstallationState.Installing);
}
else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed");
UpdateStateWithCallback(id, AppInstallationState.Installed);
}
else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error");
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}");
}
}
private void HandlePoliciesDiscovered(string policiesJson)
{
try
{
var policies = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(policiesJson);
if (policies != null)
{
// Ignore user-targeted packages during device setup (we only track device phase in general)
_packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
OnPoliciesDiscovered?.Invoke(policiesJson);
}
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}");
}
}
private void HandleCancelStuckAndSetCurrent(string newId)
{
if (string.IsNullOrEmpty(newId)) return;
// If current app is stuck in an active state, mark it as skipped
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing ||
currentPkg.InstallationState == AppInstallationState.InProgress ||
currentPkg.InstallationState == AppInstallationState.Downloading))
{
_logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}");
UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
}
_packageStates.SetCurrent(newId);
}
private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
// Set error context before state change so it's available in ToEventData()
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId))
pkg.SetErrorContext(errorPatternId, errorDetail);
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateState(id, newState, progressPercent);
if (changed)
{
_logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
OnAppStateChanged?.Invoke(pkg, oldState, newState);
// Check if all apps are now completed - only fire once
if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
{
_allAppsCompletedFired = true;
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
OnAllAppsCompleted?.Invoke();
}
}
}
private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes);
if (changed)
{
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
}
}
private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false)
{
if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase)
return;
var patterns = new List<CompiledPattern>(_patternsAlways);
if (logPhaseIsCurrentPhase)
{
patterns.AddRange(_patternsCurrentPhase);
_lastLogTimestamp = DateTime.MinValue; // Reset simulation delay
}
else
{
patterns.AddRange(_patternsOtherPhases);
}
_activePatterns = patterns;
_packageStates.SetCurrent(""); // Reset current package
_logPhaseIsCurrentPhase = logPhaseIsCurrentPhase;
}
private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token)
{
if (!SimulationMode || logTimestamp == DateTime.MinValue)
return;
if (_lastLogTimestamp != DateTime.MinValue)
{
var timeSpan = logTimestamp - _lastLogTimestamp;
if (timeSpan.TotalMilliseconds > 0)
{
var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor);
delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds
if (delayMs > 0)
await Task.Delay(delayMs, token);
}
}
_lastLogTimestamp = logTimestamp;
}
public void Dispose()
{
Stop();
_cts?.Dispose();
}
private void WriteMatchLog(string sourceFile, string rawLine, string patternId)
{
if (string.IsNullOrEmpty(_matchLogPath)) return;
try
{
var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}";
lock (_matchLogLock)
{
File.AppendAllText(_matchLogPath, entry + Environment.NewLine);
}
}
catch { }
}
/// <summary>
/// A compiled regex pattern with its action and parameters
/// </summary>
private class CompiledPattern
{
public string PatternId { get; set; }
public Regex Regex { get; set; }
public string Action { get; set; }
public Dictionary<string, string> Parameters { get; set; }
}
}
}
@@ -0,0 +1,368 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
using AutopilotMonitor.Shared;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent
{
partial class Program
{
static string GetTenantIdFromRegistry()
{
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
return null;
foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6)
{
var tenantId = enrollmentKey.GetValue("AADTenantID");
if (tenantId != null)
return tenantId.ToString();
}
}
}
}
}
catch { }
return null;
}
static AgentConfiguration LoadConfiguration(string[] args = null)
{
var noAuth = args?.Contains("--no-auth") ?? false;
var noCleanup = args?.Contains("--no-cleanup") ?? false;
var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false;
var disableGeoLoc = args?.Contains("--disable-geolocation") ?? false;
var newSession = args?.Contains("--new-session") ?? false;
var keepLogFile = args?.Contains("--keep-logfile") ?? false;
string certThumbprint = null;
string tenantIdOverride = null;
string apiBaseUrlOverride = null;
string imeLogPathOverride = null;
string imeMatchLogPath = null;
string replayLogDir = null;
string bootstrapToken = null;
double replaySpeedFactor = 50;
if (args != null)
{
var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length)
certThumbprint = args[thumbprintIndex + 1];
var tenantIdIndex = Array.IndexOf(args, "--tenant-id");
if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length)
tenantIdOverride = args[tenantIdIndex + 1];
// Local backend URL override for debugging/testing.
// Supported aliases:
// --api-url https://...
// --backend-api https://...
apiBaseUrlOverride = GetArgValue(args, "--api-url", "--backend-api");
var imeLogPathIndex = Array.IndexOf(args, "--ime-log-path");
if (imeLogPathIndex >= 0 && imeLogPathIndex + 1 < args.Length)
imeLogPathOverride = args[imeLogPathIndex + 1];
var imeMatchLogIndex = Array.IndexOf(args, "--ime-match-log");
if (imeMatchLogIndex >= 0 && imeMatchLogIndex + 1 < args.Length)
imeMatchLogPath = args[imeMatchLogIndex + 1];
var replayLogDirIndex = Array.IndexOf(args, "--replay-log-dir");
if (replayLogDirIndex >= 0 && replayLogDirIndex + 1 < args.Length)
replayLogDir = args[replayLogDirIndex + 1];
var replaySpeedIndex = Array.IndexOf(args, "--replay-speed-factor");
if (replaySpeedIndex >= 0 && replaySpeedIndex + 1 < args.Length)
if (double.TryParse(args[replaySpeedIndex + 1], out var speed))
replaySpeedFactor = speed;
var bootstrapTokenIndex = Array.IndexOf(args, "--bootstrap-token");
if (bootstrapTokenIndex >= 0 && bootstrapTokenIndex + 1 < args.Length)
bootstrapToken = args[bootstrapTokenIndex + 1];
}
// Defaults
string apiBaseUrl = Constants.ApiBaseUrl;
int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds;
bool selfDestructOnComplete = false;
bool rebootOnCompleteConfig = false;
bool enableGeoLocationConfig = true;
bool useClientCertAuthConfig = true;
// Environment variable overrides built-in default
apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl;
// CLI override wins over environment variable/default
if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride))
apiBaseUrl = apiBaseUrlOverride;
if (noCleanup)
{
selfDestructOnComplete = false;
}
// Determine TenantId: command-line arg > registry > fallback
string tenantId;
if (!string.IsNullOrEmpty(tenantIdOverride))
{
tenantId = tenantIdOverride;
}
else
{
tenantId = GetTenantIdFromRegistry();
}
var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var sessionPersist = new SessionPersistence(dataDirectory);
if (newSession)
sessionPersist.DeleteSession();
var sessionId = sessionPersist.LoadOrCreateSessionId();
// Bootstrap config: if no --bootstrap-token CLI arg, check for persisted config file.
// The OOBE bootstrap script calls --install --bootstrap-token {TOKEN} --tenant-id {TENANTID}.
// RunInstallMode persists these to bootstrap-config.json so the Scheduled Task picks them up
// on restart (the task command line has no args — just the exe path).
var bootstrapConfigPath = Path.Combine(dataDirectory, "bootstrap-config.json");
if (string.IsNullOrEmpty(bootstrapToken) && File.Exists(bootstrapConfigPath))
{
try
{
var json = File.ReadAllText(bootstrapConfigPath);
var bootstrapConfig = Newtonsoft.Json.JsonConvert.DeserializeObject<BootstrapConfigFile>(json);
if (bootstrapConfig != null)
{
bootstrapToken = bootstrapConfig.BootstrapToken;
if (string.IsNullOrEmpty(tenantIdOverride) && !string.IsNullOrEmpty(bootstrapConfig.TenantId))
{
tenantId = bootstrapConfig.TenantId;
}
// Also set noAuth so cert search is skipped
noAuth = true;
}
}
catch { /* ignore corrupt file */ }
}
var useBootstrapToken = !string.IsNullOrEmpty(bootstrapToken);
return new AgentConfiguration
{
ApiBaseUrl = apiBaseUrl,
SessionId = sessionId,
TenantId = tenantId,
SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
UploadIntervalSeconds = uploadIntervalSeconds,
MaxBatchSize = Constants.MaxBatchSize,
LogLevel = AgentLogLevel.Info,
UseClientCertAuth = !noAuth && !useBootstrapToken && useClientCertAuthConfig,
ClientCertThumbprint = certThumbprint,
SelfDestructOnComplete = selfDestructOnComplete,
RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig,
EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig,
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
ReplayLogDir = replayLogDir,
ReplaySpeedFactor = replaySpeedFactor,
KeepLogFile = keepLogFile,
BootstrapToken = bootstrapToken,
UseBootstrapTokenAuth = useBootstrapToken
};
}
private static string GetArgValue(string[] args, params string[] names)
{
if (args == null || names == null || names.Length == 0)
return null;
for (int i = 0; i < args.Length - 1; i++)
{
foreach (var name in names)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
}
return null;
}
/// <summary>
/// Checks for enrollment complete marker and handles cleanup retry if needed.
/// Returns true if marker was found and agent should exit.
/// </summary>
static bool CheckEnrollmentCompleteMarker(AgentConfiguration config, AgentLogger logger, bool consoleMode)
{
var stateDirectory = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
if (!File.Exists(markerPath))
{
// No marker - proceed with normal enrollment
return false;
}
logger.Info("Enrollment complete marker detected from previous session");
if (!config.SelfDestructOnComplete)
{
// No cleanup configured - just exit
logger.Info("Enrollment already completed (SelfDestructOnComplete is disabled). Agent will exit.");
if (consoleMode)
Console.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit.");
return true;
}
// Self-destruct is configured - attempt it now in case scheduled task failed
logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed)...");
if (consoleMode)
Console.WriteLine("Enrollment already completed. Attempting cleanup retry...");
try
{
using (var service = new MonitoringService(config, logger, GetAgentVersion()))
{
service.TriggerCleanup();
}
logger.Info("Cleanup retry completed. Agent will exit.");
if (consoleMode)
Console.WriteLine("Cleanup retry completed. Agent will exit.");
}
catch (Exception ex)
{
logger.Warning($"Cleanup retry failed: {ex.Message}");
if (consoleMode)
Console.WriteLine($"WARNING: Cleanup retry failed: {ex.Message}");
}
return true;
}
/// <summary>
/// Emergency break: checks if the current session has been alive longer than the absolute maximum.
/// Prevents zombie agents that stay on the device forever due to logic errors preventing
/// normal enrollment completion. Respects WhiteGlove scenarios (skips check during resume).
/// Returns true if emergency break triggered and agent should exit.
/// </summary>
static bool CheckSessionAgeEmergencyBreak(AgentConfiguration config, AgentLogger logger, bool consoleMode)
{
try
{
var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var persistence = new SessionPersistence(dataDir);
// Skip check if WhiteGlove-paused (device powered off between Part 1 and Part 2).
// The timer will be reset when MonitoringService.Start() processes the WhiteGlove resume.
if (persistence.IsWhiteGloveResume())
{
logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check");
return false;
}
var sessionCreatedAt = persistence.LoadSessionCreatedAt();
if (sessionCreatedAt == null)
{
// Older agent without session.created — initialize the file now.
// The emergency break will only trigger on subsequent restarts.
if (persistence.SessionExists())
{
persistence.SaveSessionCreatedAt(DateTime.UtcNow);
logger.Info("Emergency break: Initialized session.created for existing session");
}
return false;
}
var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours;
var maxAgeHours = config.AbsoluteMaxSessionHours;
if (sessionAgeHours <= maxAgeHours)
{
logger.Info($"Emergency break: Session age {sessionAgeHours:F1}h within limit ({maxAgeHours}h)");
return false;
}
// Session too old — emergency self-destruct
logger.Warning($"EMERGENCY BREAK: Session age {sessionAgeHours:F1}h exceeds maximum {maxAgeHours}h — forcing cleanup");
if (consoleMode)
Console.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {maxAgeHours}h). Forcing cleanup.");
// Write enrollment-complete marker so next start exits cleanly
var stateDir = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
Directory.CreateDirectory(stateDir);
var markerPath = Path.Combine(stateDir, "enrollment-complete.marker");
File.WriteAllText(markerPath, $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)");
if (config.SelfDestructOnComplete)
{
using (var service = new MonitoringService(config, logger, GetAgentVersion()))
{
service.TriggerCleanup();
}
}
// Clean up session files
persistence.DeleteSession();
logger.Info("Emergency break: Cleanup completed. Agent will exit.");
return true;
}
catch (Exception ex)
{
logger.Error($"Emergency break check failed: {ex.Message}", ex);
return false; // Don't block startup on check failure
}
}
static string GetAgentVersion()
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informationalVersion))
return informationalVersion;
var fileVersion = assembly
.GetCustomAttribute<AssemblyFileVersionAttribute>()
?.Version;
if (!string.IsNullOrWhiteSpace(fileVersion))
return fileVersion;
return assembly.GetName().Version?.ToString() ?? "unknown";
}
catch
{
return "unknown";
}
}
}
}
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent
{
partial class Program
{
/// <summary>
/// Fetches the latest remote config, executes all startup gather rules against the
/// current session (or a freshly registered one if no session.id exists), uploads the
/// collected events, then exits. Intended for manual testing and debugging.
/// </summary>
static void RunGatherRulesMode(string[] args)
{
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
if (consoleMode)
{
Console.WriteLine("Autopilot Monitor Agent — Run Gather Rules");
Console.WriteLine("==========================================");
Console.WriteLine();
}
try
{
var config = LoadConfiguration(args);
var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory);
var logger = new AgentLogger(logDir, config.LogLevel);
var agentVersion = GetAgentVersion();
// Always use a fresh session so gather-rules runs appear as a
// distinct portal entry and never pollute an active enrollment session.
config.SessionId = Guid.NewGuid().ToString();
logger.Info("======================= --run-gather-rules mode =======================");
if (consoleMode)
{
Console.WriteLine($"Session ID: {config.SessionId} (new, ephemeral)");
Console.WriteLine($"Tenant ID: {config.TenantId}");
Console.WriteLine($"API URL: {config.ApiBaseUrl}");
Console.WriteLine();
}
using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger))
using (var remoteConfigService = new RemoteConfigService(apiClient, config.TenantId, logger))
{
// Step 1: Fetch remote config (gather rules + settings)
logger.Info("Fetching remote configuration...");
if (consoleMode) Console.WriteLine("Fetching remote configuration...");
try
{
remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15));
logger.Info("Remote config fetched successfully");
if (consoleMode) Console.WriteLine("Remote config fetched.");
}
catch (Exception ex)
{
logger.Warning($"Failed to fetch remote config (continuing): {ex.Message}");
if (consoleMode) Console.WriteLine($"WARNING: Remote config fetch failed: {ex.Message}");
}
var remoteConfig = remoteConfigService.CurrentConfig;
var rules = remoteConfig?.GatherRules;
var startupRules = rules?
.Where(r => r.Enabled && r.Trigger == "startup")
.ToList()
?? new List<GatherRule>();
if (startupRules.Count == 0)
{
logger.Info("No enabled startup gather rules found — nothing to execute");
if (consoleMode) Console.WriteLine("No startup gather rules found. Nothing to execute.");
return;
}
// Step 2: Register session (or re-use existing — backend handles both)
logger.Info("Registering session with backend...");
if (consoleMode) Console.WriteLine("Registering session...");
try
{
var registration = new SessionRegistration
{
SessionId = config.SessionId,
TenantId = config.TenantId,
SerialNumber = DeviceInfoProvider.GetSerialNumber(),
Manufacturer = DeviceInfoProvider.GetManufacturer(),
Model = DeviceInfoProvider.GetModel(),
DeviceName = Environment.MachineName,
OsBuild = Environment.OSVersion.Version.ToString(),
OsEdition = DeviceInfoProvider.GetOsEdition(),
OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
StartedAt = DateTime.UtcNow,
AgentVersion = agentVersion,
EnrollmentType = "gather_rules"
};
var regResponse = apiClient.RegisterSessionAsync(registration).Result;
if (regResponse.Success)
logger.Info($"Session registered: {regResponse.SessionId}");
else
logger.Warning($"Session registration: {regResponse.Message}");
}
catch (Exception ex)
{
logger.Warning($"Session registration failed (continuing): {ex.Message}");
}
// Step 3: Collect events in-memory (avoids conflicting with a running agent's spool)
var collectedEvents = new List<EnrollmentEvent>();
var evtLock = new object();
long sequence = 0;
Action<EnrollmentEvent> emitEvent = evt =>
{
evt.Sequence = Interlocked.Increment(ref sequence);
lock (evtLock)
collectedEvents.Add(evt);
logger.Debug($"Gather event: {evt.EventType} — {evt.Message}");
if (consoleMode)
Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] [{evt.EventType}] {evt.Message}");
};
// Step 4: Execute startup gather rules
logger.Info($"Executing {startupRules.Count} startup gather rule(s)...");
if (consoleMode)
Console.WriteLine($"Executing {startupRules.Count} startup gather rule(s)...");
emitEvent(new EnrollmentEvent
{
SessionId = config.SessionId,
TenantId = config.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "gather_rules_collection_started",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.Start,
Message = $"Gather rules collection started ({startupRules.Count} rule(s))",
Data = new Dictionary<string, object>
{
{ "ruleCount", startupRules.Count },
{ "agentVersion", agentVersion }
}
});
using (var executor = new GatherRuleExecutor(
config.SessionId,
config.TenantId,
emitEvent,
logger,
config.ImeLogPathOverride))
{
executor.UpdateRules(rules);
// Step 5: Wait for all startup rules to finish — no fixed sleep,
// exits as soon as the last rule completes (120 s hard cap).
const int maxWaitSeconds = 120;
logger.Info($"Waiting for startup rules to complete (max {maxWaitSeconds}s)...");
if (consoleMode)
Console.WriteLine($"Running {startupRules.Count} rule(s)...");
var allCompleted = executor.WaitForStartupRules(maxWaitSeconds);
if (!allCompleted)
{
logger.Warning($"Some gather rules did not complete within {maxWaitSeconds}s (results may be incomplete)");
if (consoleMode)
Console.WriteLine($"WARNING: Some rules timed out after {maxWaitSeconds}s.");
}
else
{
logger.Info("All startup rules completed");
if (consoleMode) Console.WriteLine("All rules completed.");
}
}
// Step 6: Upload all collected events
List<EnrollmentEvent> eventsToUpload;
lock (evtLock)
eventsToUpload = new List<EnrollmentEvent>(collectedEvents);
// +1 because the completed event itself is also uploaded
var totalEventCount = eventsToUpload.Count + 1;
emitEvent(new EnrollmentEvent
{
SessionId = config.SessionId,
TenantId = config.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "gather_rules_collection_completed",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.Start,
Message = $"Gather rules collection completed ({totalEventCount} event(s) collected)",
Data = new Dictionary<string, object>
{
{ "totalEvents", totalEventCount }
}
});
// Re-snapshot after adding the completed event
lock (evtLock)
eventsToUpload = new List<EnrollmentEvent>(collectedEvents);
logger.Info($"Uploading {eventsToUpload.Count} collected event(s)...");
if (consoleMode)
Console.WriteLine($"Uploading {eventsToUpload.Count} event(s)...");
if (eventsToUpload.Count > 0)
{
try
{
var request = new IngestEventsRequest
{
SessionId = config.SessionId,
TenantId = config.TenantId,
Events = eventsToUpload
};
var uploadResponse = apiClient.IngestEventsAsync(request).Result;
if (uploadResponse.Success)
{
logger.Info($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully");
if (consoleMode)
Console.WriteLine($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully.");
}
else
{
logger.Warning($"Upload failed: {uploadResponse.Message}");
if (consoleMode)
Console.WriteLine($"Upload failed: {uploadResponse.Message}");
}
}
catch (Exception ex)
{
logger.Error("Failed to upload events", ex);
if (consoleMode)
Console.WriteLine($"ERROR uploading events: {ex.Message}");
}
}
else
{
logger.Info("No events were collected");
if (consoleMode)
Console.WriteLine("No events were collected.");
}
}
if (consoleMode)
Console.WriteLine("\nGather rules run complete.");
}
catch (Exception ex)
{
if (consoleMode)
{
Console.WriteLine($"ERROR: {ex.Message}");
Console.WriteLine(ex.StackTrace);
}
}
}
}
}
@@ -0,0 +1,208 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent
{
partial class Program
{
static void RunInstallMode(string[] args)
{
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
try
{
logger.Info("======================= Agent install mode (--install) =======================");
EnsureAgentDirectories(logger);
var sourceExePath = Assembly.GetExecutingAssembly().Location;
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
var targetAgentDir = Environment.ExpandEnvironmentVariables(Path.Combine(Constants.AgentDataDirectory, "Agent"));
var targetExePath = Path.Combine(targetAgentDir, "AutopilotMonitor.Agent.exe");
if (!string.Equals(
Path.GetFullPath(sourceDir).TrimEnd('\\'),
Path.GetFullPath(targetAgentDir).TrimEnd('\\'),
StringComparison.OrdinalIgnoreCase))
{
logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'.");
CopyDirectory(sourceDir, targetAgentDir, logger);
}
else
{
logger.Info("Agent already running from target install directory; payload copy not required.");
}
// Persist bootstrap config if --bootstrap-token was provided.
// The Scheduled Task command line has no args, so the agent reads this file on restart.
var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
var tenantIdArg = GetArgValue(args, "--tenant-id");
if (!string.IsNullOrEmpty(bootstrapTokenArg))
{
var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var bootstrapConfigPath = Path.Combine(dataDir, "bootstrap-config.json");
var bootstrapConfig = new BootstrapConfigFile
{
BootstrapToken = bootstrapTokenArg,
TenantId = tenantIdArg
};
File.WriteAllText(bootstrapConfigPath, Newtonsoft.Json.JsonConvert.SerializeObject(bootstrapConfig));
logger.Info("Bootstrap config persisted for Scheduled Task");
}
var taskName = Constants.ScheduledTaskName;
var taskCommand = $"\"{targetExePath}\"";
logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}");
var createExitCode = RunProcess(
"schtasks.exe",
$"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F",
logger);
if (createExitCode != 0)
{
throw new InvalidOperationException($"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode})");
}
logger.Info($"Scheduled Task '{taskName}' created/updated successfully");
var startExitCode = RunProcess(
"schtasks.exe",
$"/Run /TN \"{taskName}\"",
logger);
if (startExitCode != 0)
{
throw new InvalidOperationException($"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode})");
}
logger.Info($"Scheduled Task '{taskName}' started successfully");
if (consoleMode)
{
Console.WriteLine("Installation completed successfully.");
Console.WriteLine($"Task: {taskName}");
Console.WriteLine($"Executable: {targetExePath}");
Console.WriteLine($"Log: {logDir}");
}
Environment.Exit(0);
}
catch (Exception ex)
{
logger.Error("Registration mode failed", ex);
if (consoleMode)
{
Console.WriteLine($"ERROR: {ex.Message}");
}
Environment.Exit(1);
}
}
private static void EnsureAgentDirectories(AgentLogger logger)
{
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var paths = new[]
{
basePath,
Path.Combine(basePath, "Agent"),
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
};
foreach (var path in paths)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
logger.Info($"Created directory: {path}");
}
else
{
logger.Debug($"Directory already exists: {path}");
}
}
}
private static int RunProcess(string fileName, string arguments, AgentLogger logger)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
logger.Info($"Executing: {fileName} {arguments}");
using (var process = Process.Start(psi))
{
if (process == null)
throw new InvalidOperationException($"Failed to start process: {fileName}");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (!string.IsNullOrWhiteSpace(stdout))
logger.Info($"Process output: {stdout.Trim()}");
if (!string.IsNullOrWhiteSpace(stderr))
logger.Warning($"Process error output: {stderr.Trim()}");
logger.Info($"Process exit code: {process.ExitCode}");
return process.ExitCode;
}
}
private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger)
{
if (!Directory.Exists(sourceDir))
throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}");
Directory.CreateDirectory(targetDir);
foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var rel = dir.Substring(sourceDir.Length).TrimStart('\\');
Directory.CreateDirectory(Path.Combine(targetDir, rel));
}
foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var rel = file.Substring(sourceDir.Length).TrimStart('\\');
var dest = Path.Combine(targetDir, rel);
Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir);
try
{
File.Copy(file, dest, true);
}
catch (Exception ex)
{
// If destination exists, keep going and let scheduled task use the latest available payload.
if (File.Exists(dest))
{
logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file.");
continue;
}
throw;
}
}
logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'");
}
}
}
+1 -809
View File
@@ -1,22 +1,15 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent
{
class Program
partial class Program
{
static void Main(string[] args)
{
@@ -41,357 +34,6 @@ namespace AutopilotMonitor.Agent
RunAgent(args);
}
static void RunInstallMode(string[] args)
{
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
try
{
logger.Info("======================= Agent install mode (--install) =======================");
EnsureAgentDirectories(logger);
var sourceExePath = Assembly.GetExecutingAssembly().Location;
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
var targetAgentDir = Environment.ExpandEnvironmentVariables(Path.Combine(Constants.AgentDataDirectory, "Agent"));
var targetExePath = Path.Combine(targetAgentDir, "AutopilotMonitor.Agent.exe");
if (!string.Equals(
Path.GetFullPath(sourceDir).TrimEnd('\\'),
Path.GetFullPath(targetAgentDir).TrimEnd('\\'),
StringComparison.OrdinalIgnoreCase))
{
logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'.");
CopyDirectory(sourceDir, targetAgentDir, logger);
}
else
{
logger.Info("Agent already running from target install directory; payload copy not required.");
}
// Persist bootstrap config if --bootstrap-token was provided.
// The Scheduled Task command line has no args, so the agent reads this file on restart.
var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
var tenantIdArg = GetArgValue(args, "--tenant-id");
if (!string.IsNullOrEmpty(bootstrapTokenArg))
{
var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var bootstrapConfigPath = Path.Combine(dataDir, "bootstrap-config.json");
var bootstrapConfig = new BootstrapConfigFile
{
BootstrapToken = bootstrapTokenArg,
TenantId = tenantIdArg
};
File.WriteAllText(bootstrapConfigPath, Newtonsoft.Json.JsonConvert.SerializeObject(bootstrapConfig));
logger.Info("Bootstrap config persisted for Scheduled Task");
}
var taskName = Constants.ScheduledTaskName;
var taskCommand = $"\"{targetExePath}\"";
logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}");
var createExitCode = RunProcess(
"schtasks.exe",
$"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F",
logger);
if (createExitCode != 0)
{
throw new InvalidOperationException($"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode})");
}
logger.Info($"Scheduled Task '{taskName}' created/updated successfully");
var startExitCode = RunProcess(
"schtasks.exe",
$"/Run /TN \"{taskName}\"",
logger);
if (startExitCode != 0)
{
throw new InvalidOperationException($"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode})");
}
logger.Info($"Scheduled Task '{taskName}' started successfully");
if (consoleMode)
{
Console.WriteLine("Installation completed successfully.");
Console.WriteLine($"Task: {taskName}");
Console.WriteLine($"Executable: {targetExePath}");
Console.WriteLine($"Log: {logDir}");
}
Environment.Exit(0);
}
catch (Exception ex)
{
logger.Error("Registration mode failed", ex);
if (consoleMode)
{
Console.WriteLine($"ERROR: {ex.Message}");
}
Environment.Exit(1);
}
}
/// <summary>
/// Fetches the latest remote config, executes all startup gather rules against the
/// current session (or a freshly registered one if no session.id exists), uploads the
/// collected events, then exits. Intended for manual testing and debugging.
/// </summary>
static void RunGatherRulesMode(string[] args)
{
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
if (consoleMode)
{
Console.WriteLine("Autopilot Monitor Agent — Run Gather Rules");
Console.WriteLine("==========================================");
Console.WriteLine();
}
try
{
var config = LoadConfiguration(args);
var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory);
var logger = new AgentLogger(logDir, config.LogLevel);
var agentVersion = GetAgentVersion();
// Always use a fresh session so gather-rules runs appear as a
// distinct portal entry and never pollute an active enrollment session.
config.SessionId = Guid.NewGuid().ToString();
logger.Info("======================= --run-gather-rules mode =======================");
if (consoleMode)
{
Console.WriteLine($"Session ID: {config.SessionId} (new, ephemeral)");
Console.WriteLine($"Tenant ID: {config.TenantId}");
Console.WriteLine($"API URL: {config.ApiBaseUrl}");
Console.WriteLine();
}
using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger))
using (var remoteConfigService = new RemoteConfigService(apiClient, config.TenantId, logger))
{
// Step 1: Fetch remote config (gather rules + settings)
logger.Info("Fetching remote configuration...");
if (consoleMode) Console.WriteLine("Fetching remote configuration...");
try
{
remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15));
logger.Info("Remote config fetched successfully");
if (consoleMode) Console.WriteLine("Remote config fetched.");
}
catch (Exception ex)
{
logger.Warning($"Failed to fetch remote config (continuing): {ex.Message}");
if (consoleMode) Console.WriteLine($"WARNING: Remote config fetch failed: {ex.Message}");
}
var remoteConfig = remoteConfigService.CurrentConfig;
var rules = remoteConfig?.GatherRules;
var startupRules = rules?
.Where(r => r.Enabled && r.Trigger == "startup")
.ToList()
?? new List<GatherRule>();
if (startupRules.Count == 0)
{
logger.Info("No enabled startup gather rules found — nothing to execute");
if (consoleMode) Console.WriteLine("No startup gather rules found. Nothing to execute.");
return;
}
// Step 2: Register session (or re-use existing — backend handles both)
logger.Info("Registering session with backend...");
if (consoleMode) Console.WriteLine("Registering session...");
try
{
var registration = new SessionRegistration
{
SessionId = config.SessionId,
TenantId = config.TenantId,
SerialNumber = DeviceInfoProvider.GetSerialNumber(),
Manufacturer = DeviceInfoProvider.GetManufacturer(),
Model = DeviceInfoProvider.GetModel(),
DeviceName = Environment.MachineName,
OsBuild = Environment.OSVersion.Version.ToString(),
OsEdition = DeviceInfoProvider.GetOsEdition(),
OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
StartedAt = DateTime.UtcNow,
AgentVersion = agentVersion,
EnrollmentType = "gather_rules"
};
var regResponse = apiClient.RegisterSessionAsync(registration).Result;
if (regResponse.Success)
logger.Info($"Session registered: {regResponse.SessionId}");
else
logger.Warning($"Session registration: {regResponse.Message}");
}
catch (Exception ex)
{
logger.Warning($"Session registration failed (continuing): {ex.Message}");
}
// Step 3: Collect events in-memory (avoids conflicting with a running agent's spool)
var collectedEvents = new List<EnrollmentEvent>();
var evtLock = new object();
long sequence = 0;
Action<EnrollmentEvent> emitEvent = evt =>
{
evt.Sequence = Interlocked.Increment(ref sequence);
lock (evtLock)
collectedEvents.Add(evt);
logger.Debug($"Gather event: {evt.EventType} — {evt.Message}");
if (consoleMode)
Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] [{evt.EventType}] {evt.Message}");
};
// Step 4: Execute startup gather rules
logger.Info($"Executing {startupRules.Count} startup gather rule(s)...");
if (consoleMode)
Console.WriteLine($"Executing {startupRules.Count} startup gather rule(s)...");
emitEvent(new EnrollmentEvent
{
SessionId = config.SessionId,
TenantId = config.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "gather_rules_collection_started",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.Start,
Message = $"Gather rules collection started ({startupRules.Count} rule(s))",
Data = new Dictionary<string, object>
{
{ "ruleCount", startupRules.Count },
{ "agentVersion", agentVersion }
}
});
using (var executor = new GatherRuleExecutor(
config.SessionId,
config.TenantId,
emitEvent,
logger,
config.ImeLogPathOverride))
{
executor.UpdateRules(rules);
// Step 5: Wait for all startup rules to finish — no fixed sleep,
// exits as soon as the last rule completes (120 s hard cap).
const int maxWaitSeconds = 120;
logger.Info($"Waiting for startup rules to complete (max {maxWaitSeconds}s)...");
if (consoleMode)
Console.WriteLine($"Running {startupRules.Count} rule(s)...");
var allCompleted = executor.WaitForStartupRules(maxWaitSeconds);
if (!allCompleted)
{
logger.Warning($"Some gather rules did not complete within {maxWaitSeconds}s (results may be incomplete)");
if (consoleMode)
Console.WriteLine($"WARNING: Some rules timed out after {maxWaitSeconds}s.");
}
else
{
logger.Info("All startup rules completed");
if (consoleMode) Console.WriteLine("All rules completed.");
}
}
// Step 6: Upload all collected events
List<EnrollmentEvent> eventsToUpload;
lock (evtLock)
eventsToUpload = new List<EnrollmentEvent>(collectedEvents);
// +1 because the completed event itself is also uploaded
var totalEventCount = eventsToUpload.Count + 1;
emitEvent(new EnrollmentEvent
{
SessionId = config.SessionId,
TenantId = config.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "gather_rules_collection_completed",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.Start,
Message = $"Gather rules collection completed ({totalEventCount} event(s) collected)",
Data = new Dictionary<string, object>
{
{ "totalEvents", totalEventCount }
}
});
// Re-snapshot after adding the completed event
lock (evtLock)
eventsToUpload = new List<EnrollmentEvent>(collectedEvents);
logger.Info($"Uploading {eventsToUpload.Count} collected event(s)...");
if (consoleMode)
Console.WriteLine($"Uploading {eventsToUpload.Count} event(s)...");
if (eventsToUpload.Count > 0)
{
try
{
var request = new IngestEventsRequest
{
SessionId = config.SessionId,
TenantId = config.TenantId,
Events = eventsToUpload
};
var uploadResponse = apiClient.IngestEventsAsync(request).Result;
if (uploadResponse.Success)
{
logger.Info($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully");
if (consoleMode)
Console.WriteLine($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully.");
}
else
{
logger.Warning($"Upload failed: {uploadResponse.Message}");
if (consoleMode)
Console.WriteLine($"Upload failed: {uploadResponse.Message}");
}
}
catch (Exception ex)
{
logger.Error("Failed to upload events", ex);
if (consoleMode)
Console.WriteLine($"ERROR uploading events: {ex.Message}");
}
}
else
{
logger.Info("No events were collected");
if (consoleMode)
Console.WriteLine("No events were collected.");
}
}
if (consoleMode)
Console.WriteLine("\nGather rules run complete.");
}
catch (Exception ex)
{
if (consoleMode)
{
Console.WriteLine($"ERROR: {ex.Message}");
Console.WriteLine(ex.StackTrace);
}
}
}
static void RunAgent(string[] args)
{
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
@@ -510,301 +152,6 @@ namespace AutopilotMonitor.Agent
}
}
static string GetTenantIdFromRegistry()
{
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
return null;
foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6)
{
var tenantId = enrollmentKey.GetValue("AADTenantID");
if (tenantId != null)
return tenantId.ToString();
}
}
}
}
}
catch { }
return null;
}
static AgentConfiguration LoadConfiguration(string[] args = null)
{
var noAuth = args?.Contains("--no-auth") ?? false;
var noCleanup = args?.Contains("--no-cleanup") ?? false;
var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false;
var disableGeoLoc = args?.Contains("--disable-geolocation") ?? false;
var newSession = args?.Contains("--new-session") ?? false;
var keepLogFile = args?.Contains("--keep-logfile") ?? false;
string certThumbprint = null;
string tenantIdOverride = null;
string apiBaseUrlOverride = null;
string imeLogPathOverride = null;
string imeMatchLogPath = null;
string replayLogDir = null;
string bootstrapToken = null;
double replaySpeedFactor = 50;
if (args != null)
{
var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length)
certThumbprint = args[thumbprintIndex + 1];
var tenantIdIndex = Array.IndexOf(args, "--tenant-id");
if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length)
tenantIdOverride = args[tenantIdIndex + 1];
// Local backend URL override for debugging/testing.
// Supported aliases:
// --api-url https://...
// --backend-api https://...
apiBaseUrlOverride = GetArgValue(args, "--api-url", "--backend-api");
var imeLogPathIndex = Array.IndexOf(args, "--ime-log-path");
if (imeLogPathIndex >= 0 && imeLogPathIndex + 1 < args.Length)
imeLogPathOverride = args[imeLogPathIndex + 1];
var imeMatchLogIndex = Array.IndexOf(args, "--ime-match-log");
if (imeMatchLogIndex >= 0 && imeMatchLogIndex + 1 < args.Length)
imeMatchLogPath = args[imeMatchLogIndex + 1];
var replayLogDirIndex = Array.IndexOf(args, "--replay-log-dir");
if (replayLogDirIndex >= 0 && replayLogDirIndex + 1 < args.Length)
replayLogDir = args[replayLogDirIndex + 1];
var replaySpeedIndex = Array.IndexOf(args, "--replay-speed-factor");
if (replaySpeedIndex >= 0 && replaySpeedIndex + 1 < args.Length)
if (double.TryParse(args[replaySpeedIndex + 1], out var speed))
replaySpeedFactor = speed;
var bootstrapTokenIndex = Array.IndexOf(args, "--bootstrap-token");
if (bootstrapTokenIndex >= 0 && bootstrapTokenIndex + 1 < args.Length)
bootstrapToken = args[bootstrapTokenIndex + 1];
}
// Defaults
string apiBaseUrl = Constants.ApiBaseUrl;
int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds;
bool selfDestructOnComplete = false;
bool rebootOnCompleteConfig = false;
bool enableGeoLocationConfig = true;
bool useClientCertAuthConfig = true;
// Environment variable overrides built-in default
apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl;
// CLI override wins over environment variable/default
if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride))
apiBaseUrl = apiBaseUrlOverride;
if (noCleanup)
{
selfDestructOnComplete = false;
}
// Determine TenantId: command-line arg > registry > fallback
string tenantId;
if (!string.IsNullOrEmpty(tenantIdOverride))
{
tenantId = tenantIdOverride;
}
else
{
tenantId = GetTenantIdFromRegistry();
}
var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var sessionPersist = new SessionPersistence(dataDirectory);
if (newSession)
sessionPersist.DeleteSession();
var sessionId = sessionPersist.LoadOrCreateSessionId();
// Bootstrap config: if no --bootstrap-token CLI arg, check for persisted config file.
// The OOBE bootstrap script calls --install --bootstrap-token {TOKEN} --tenant-id {TENANTID}.
// RunInstallMode persists these to bootstrap-config.json so the Scheduled Task picks them up
// on restart (the task command line has no args — just the exe path).
var bootstrapConfigPath = Path.Combine(dataDirectory, "bootstrap-config.json");
if (string.IsNullOrEmpty(bootstrapToken) && File.Exists(bootstrapConfigPath))
{
try
{
var json = File.ReadAllText(bootstrapConfigPath);
var bootstrapConfig = Newtonsoft.Json.JsonConvert.DeserializeObject<BootstrapConfigFile>(json);
if (bootstrapConfig != null)
{
bootstrapToken = bootstrapConfig.BootstrapToken;
if (string.IsNullOrEmpty(tenantIdOverride) && !string.IsNullOrEmpty(bootstrapConfig.TenantId))
{
tenantId = bootstrapConfig.TenantId;
}
// Also set noAuth so cert search is skipped
noAuth = true;
}
}
catch { /* ignore corrupt file */ }
}
var useBootstrapToken = !string.IsNullOrEmpty(bootstrapToken);
return new AgentConfiguration
{
ApiBaseUrl = apiBaseUrl,
SessionId = sessionId,
TenantId = tenantId,
SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
UploadIntervalSeconds = uploadIntervalSeconds,
MaxBatchSize = Constants.MaxBatchSize,
LogLevel = AgentLogLevel.Info,
UseClientCertAuth = !noAuth && !useBootstrapToken && useClientCertAuthConfig,
ClientCertThumbprint = certThumbprint,
SelfDestructOnComplete = selfDestructOnComplete,
RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig,
EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig,
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
ReplayLogDir = replayLogDir,
ReplaySpeedFactor = replaySpeedFactor,
KeepLogFile = keepLogFile,
BootstrapToken = bootstrapToken,
UseBootstrapTokenAuth = useBootstrapToken
};
}
private static string GetArgValue(string[] args, params string[] names)
{
if (args == null || names == null || names.Length == 0)
return null;
for (int i = 0; i < args.Length - 1; i++)
{
foreach (var name in names)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
}
return null;
}
private static void EnsureAgentDirectories(AgentLogger logger)
{
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var paths = new[]
{
basePath,
Path.Combine(basePath, "Agent"),
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
};
foreach (var path in paths)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
logger.Info($"Created directory: {path}");
}
else
{
logger.Debug($"Directory already exists: {path}");
}
}
}
private static int RunProcess(string fileName, string arguments, AgentLogger logger)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
logger.Info($"Executing: {fileName} {arguments}");
using (var process = Process.Start(psi))
{
if (process == null)
throw new InvalidOperationException($"Failed to start process: {fileName}");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (!string.IsNullOrWhiteSpace(stdout))
logger.Info($"Process output: {stdout.Trim()}");
if (!string.IsNullOrWhiteSpace(stderr))
logger.Warning($"Process error output: {stderr.Trim()}");
logger.Info($"Process exit code: {process.ExitCode}");
return process.ExitCode;
}
}
private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger)
{
if (!Directory.Exists(sourceDir))
throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}");
Directory.CreateDirectory(targetDir);
foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var rel = dir.Substring(sourceDir.Length).TrimStart('\\');
Directory.CreateDirectory(Path.Combine(targetDir, rel));
}
foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var rel = file.Substring(sourceDir.Length).TrimStart('\\');
var dest = Path.Combine(targetDir, rel);
Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir);
try
{
File.Copy(file, dest, true);
}
catch (Exception ex)
{
// If destination exists, keep going and let scheduled task use the latest available payload.
if (File.Exists(dest))
{
logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file.");
continue;
}
throw;
}
}
logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'");
}
/// <summary>
/// Checks if another AutopilotMonitor.Agent.exe process is already running.
/// </summary>
@@ -824,161 +171,6 @@ namespace AutopilotMonitor.Agent
return false;
}
}
/// <summary>
/// Checks for enrollment complete marker and handles cleanup retry if needed.
/// Returns true if marker was found and agent should exit.
/// </summary>
static bool CheckEnrollmentCompleteMarker(AgentConfiguration config, AgentLogger logger, bool consoleMode)
{
var stateDirectory = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
if (!File.Exists(markerPath))
{
// No marker - proceed with normal enrollment
return false;
}
logger.Info("Enrollment complete marker detected from previous session");
if (!config.SelfDestructOnComplete)
{
// No cleanup configured - just exit
logger.Info("Enrollment already completed (SelfDestructOnComplete is disabled). Agent will exit.");
if (consoleMode)
Console.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit.");
return true;
}
// Self-destruct is configured - attempt it now in case scheduled task failed
logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed)...");
if (consoleMode)
Console.WriteLine("Enrollment already completed. Attempting cleanup retry...");
try
{
using (var service = new MonitoringService(config, logger, GetAgentVersion()))
{
service.TriggerCleanup();
}
logger.Info("Cleanup retry completed. Agent will exit.");
if (consoleMode)
Console.WriteLine("Cleanup retry completed. Agent will exit.");
}
catch (Exception ex)
{
logger.Warning($"Cleanup retry failed: {ex.Message}");
if (consoleMode)
Console.WriteLine($"WARNING: Cleanup retry failed: {ex.Message}");
}
return true;
}
/// <summary>
/// Emergency break: checks if the current session has been alive longer than the absolute maximum.
/// Prevents zombie agents that stay on the device forever due to logic errors preventing
/// normal enrollment completion. Respects WhiteGlove scenarios (skips check during resume).
/// Returns true if emergency break triggered and agent should exit.
/// </summary>
static bool CheckSessionAgeEmergencyBreak(AgentConfiguration config, AgentLogger logger, bool consoleMode)
{
try
{
var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var persistence = new SessionPersistence(dataDir);
// Skip check if WhiteGlove-paused (device powered off between Part 1 and Part 2).
// The timer will be reset when MonitoringService.Start() processes the WhiteGlove resume.
if (persistence.IsWhiteGloveResume())
{
logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check");
return false;
}
var sessionCreatedAt = persistence.LoadSessionCreatedAt();
if (sessionCreatedAt == null)
{
// Older agent without session.created — initialize the file now.
// The emergency break will only trigger on subsequent restarts.
if (persistence.SessionExists())
{
persistence.SaveSessionCreatedAt(DateTime.UtcNow);
logger.Info("Emergency break: Initialized session.created for existing session");
}
return false;
}
var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours;
var maxAgeHours = config.AbsoluteMaxSessionHours;
if (sessionAgeHours <= maxAgeHours)
{
logger.Info($"Emergency break: Session age {sessionAgeHours:F1}h within limit ({maxAgeHours}h)");
return false;
}
// Session too old — emergency self-destruct
logger.Warning($"EMERGENCY BREAK: Session age {sessionAgeHours:F1}h exceeds maximum {maxAgeHours}h — forcing cleanup");
if (consoleMode)
Console.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {maxAgeHours}h). Forcing cleanup.");
// Write enrollment-complete marker so next start exits cleanly
var stateDir = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
Directory.CreateDirectory(stateDir);
var markerPath = Path.Combine(stateDir, "enrollment-complete.marker");
File.WriteAllText(markerPath, $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)");
if (config.SelfDestructOnComplete)
{
using (var service = new MonitoringService(config, logger, GetAgentVersion()))
{
service.TriggerCleanup();
}
}
// Clean up session files
persistence.DeleteSession();
logger.Info("Emergency break: Cleanup completed. Agent will exit.");
return true;
}
catch (Exception ex)
{
logger.Error($"Emergency break check failed: {ex.Message}", ex);
return false; // Don't block startup on check failure
}
}
static string GetAgentVersion()
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informationalVersion))
return informationalVersion;
var fileVersion = assembly
.GetCustomAttribute<AssemblyFileVersionAttribute>()
?.Version;
if (!string.IsNullOrWhiteSpace(fileVersion))
return fileVersion;
return assembly.GetName().Version?.ToString() ?? "unknown";
}
catch
{
return "unknown";
}
}
}
/// <summary>
@@ -1 +1 @@
243
270
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
namespace AutopilotMonitor.Functions.Functions.Admin;
public class AutopilotDeviceValidationConsentFunction
{
@@ -3,7 +3,7 @@ using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Timer-triggered Azure Function that runs daily maintenance.
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
public class DeviceBlockFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
public class GetAllBlockedDevicesFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
public class GetAuditLogsFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
public class GetGalacticAuditLogsFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Central reseed endpoint that fetches all rule types from GitHub and writes them to Table Storage.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// Tenant Admin Management endpoints
@@ -11,7 +11,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// Tenant Offboarding endpoint
@@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Allows Galactic Admins to manually trigger maintenance tasks
@@ -1,12 +1,13 @@
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/config — cert-free agent config for pre-enrollment agents.
@@ -1,5 +1,6 @@
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
@@ -7,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// POST /api/bootstrap/ingest — cert-free ingest for pre-enrollment agents.
@@ -1,5 +1,6 @@
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
@@ -9,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// POST /api/bootstrap/register-session — cert-free session registration for pre-enrollment agents.
@@ -1,12 +1,13 @@
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// POST /api/bootstrap/error — cert-free error reporting for pre-enrollment agents.
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// POST /api/bootstrap/sessions — Create a new bootstrap session for OOBE agent deployment.
@@ -9,7 +9,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/sessions?tenantId={tenantId} — List bootstrap sessions for a tenant.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// DELETE /api/bootstrap/sessions/{code}?tenantId={tenantId} — Revoke a bootstrap session.
@@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
public class GetAdminConfigurationFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
/// <summary>
/// Returns agent configuration including collector toggles and active gather rules
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
public class GetAllTenantConfigurationsFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
public class GetTenantConfigurationFunction
{
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateAdminConfigurationFunction
{
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateTenantConfigurationFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateTenantSecurityBypassFunction
{
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
/// <summary>
/// Returns a download URL for a diagnostics package stored in the tenant's Blob Storage.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
/// <summary>
/// Returns a short-lived SAS URL for diagnostics package upload.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
namespace AutopilotMonitor.Functions.Functions.Infrastructure;
/// <summary>
/// Authentication and authorization endpoints
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class HealthCheckFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRAddToGroupFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRNegotiateFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRRemoveFromGroupFunction
{
@@ -0,0 +1,303 @@
using System.IO.Compression;
using System.Linq;
using System;
using System.Net;
using System.Text;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions.Ingest
{
/// <summary>
/// Partial: Request parsing, event normalization, app install aggregation helpers.
/// </summary>
public partial class IngestEventsFunction
{
private async Task<IngestEventsRequest> ParseNdjsonGzipRequest(Stream body, string? tenantId = null)
{
// Get configuration for payload size limit (use default if tenantId not available yet)
var config = !string.IsNullOrEmpty(tenantId)
? await _configService.GetConfigurationAsync(tenantId)
: null;
var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024;
// Decompress gzip with size limit protection
using var decompressed = new MemoryStream();
using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true))
{
// Copy with size limit to prevent memory exhaustion attacks
var buffer = new byte[8192];
int bytesRead;
long totalBytesRead = 0;
while ((bytesRead = await gzip.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
totalBytesRead += bytesRead;
// Check if we've exceeded the maximum payload size
if (totalBytesRead > maxPayloadSizeBytes)
{
throw new InvalidOperationException(
$"NDJSON payload size exceeds maximum allowed size of {config?.MaxNdjsonPayloadSizeMB ?? 5} MB (decompressed). " +
$"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB"
);
}
await decompressed.WriteAsync(buffer, 0, bytesRead);
}
_logger.LogDebug($"NDJSON payload decompressed: {totalBytesRead / 1024.0:F2} KB (limit: {maxPayloadSizeBytes / 1024.0 / 1024.0} MB)");
}
decompressed.Position = 0;
var ndjson = await new StreamReader(decompressed, Encoding.UTF8).ReadToEndAsync();
// Parse NDJSON (newline-delimited JSON)
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 1)
{
throw new InvalidOperationException("NDJSON must contain at least metadata line");
}
// First line: metadata
var metadata = JsonConvert.DeserializeObject<NdjsonMetadata>(lines[0]);
if (metadata == null)
{
throw new InvalidOperationException("Failed to parse NDJSON metadata");
}
// Subsequent lines: events
var events = new List<EnrollmentEvent>();
for (int i = 1; i < lines.Length; i++)
{
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(lines[i]);
if (evt != null)
{
NormalizeEventData(evt);
events.Add(evt);
}
}
return new IngestEventsRequest
{
SessionId = metadata.SessionId,
TenantId = metadata.TenantId,
Events = events
};
}
/// <summary>
/// Normalizes event Data dictionary by converting Newtonsoft JToken objects to native .NET types.
/// Required because Newtonsoft.Json deserializes nested objects as JObject/JArray, which
/// System.Text.Json (used by SignalR) cannot serialize correctly - producing [[[]]] instead of real values.
/// </summary>
private static void NormalizeEventData(EnrollmentEvent evt)
{
if (evt.Data == null || evt.Data.Count == 0) return;
var normalized = new Dictionary<string, object>();
foreach (var kvp in evt.Data)
normalized[kvp.Key] = ConvertJTokenToNative(kvp.Value);
evt.Data = normalized;
}
private static object ConvertJTokenToNative(object value)
{
if (value is JArray jArray)
return jArray.Select(item => ConvertJTokenToNative(item)).ToList<object>();
if (value is JObject jObject)
{
var dict = new Dictionary<string, object>();
foreach (var prop in jObject.Properties())
dict[prop.Name] = ConvertJTokenToNative(prop.Value);
return dict;
}
if (value is JValue jValue)
return jValue.Value ?? string.Empty;
return value;
}
/// <summary>
/// Aggregates app install events into AppInstallSummary records
/// </summary>
private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallAggregationState> summaries)
{
// Agent sends: app_install_started, app_install_completed, app_install_failed, app_download_started, download_progress
// Support both legacy (app_install_start/complete) and current agent event types
bool isRelevant =
evt.EventType == "app_install_started" || evt.EventType == "app_install_start" ||
evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" ||
evt.EventType == "app_install_failed" ||
evt.EventType == "app_download_started" ||
evt.EventType == "app_install_skipped" ||
evt.EventType == "download_progress" ||
evt.EventType == "do_telemetry";
if (!isRelevant) return;
var appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null;
if (string.IsNullOrEmpty(appName)) return;
if (!summaries.TryGetValue(appName, out var state))
{
state = new AppInstallAggregationState
{
Summary = new AppInstallSummary
{
AppName = appName,
SessionId = sessionId,
TenantId = tenantId,
StartedAt = evt.Timestamp
}
};
summaries[appName] = state;
}
var summary = state.Summary;
switch (evt.EventType)
{
case "app_install_started":
case "app_install_start":
if (!state.InstallStartedAt.HasValue || evt.Timestamp < state.InstallStartedAt.Value)
state.InstallStartedAt = evt.Timestamp;
if (summary.Status == "InProgress" || summary.Status == string.Empty)
summary.Status = "InProgress";
break;
case "app_download_started":
if (!state.DownloadStartedAt.HasValue || evt.Timestamp < state.DownloadStartedAt.Value)
state.DownloadStartedAt = evt.Timestamp;
if (summary.Status == "InProgress" || summary.Status == string.Empty)
summary.Status = "InProgress";
break;
case "app_install_completed":
case "app_install_complete":
summary.Status = "Succeeded";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds);
break;
case "app_install_failed":
summary.Status = "Failed";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds);
// Agent does not send errorCode/errorMessage in Data — use the event message
summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true
? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty;
summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true
? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty;
break;
case "app_install_skipped":
// Mark as succeeded (skipped = already installed / not applicable) with 0 duration
if (summary.Status == "InProgress")
summary.Status = "Succeeded";
break;
case "download_progress":
// Agent sends "bytesDownloaded" (not "bytes_downloaded")
var bytesKey = evt.Data?.ContainsKey("bytesDownloaded") == true ? "bytesDownloaded"
: evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null;
if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes);
break;
case "do_telemetry":
if (evt.Data != null)
{
if (evt.Data.ContainsKey("doFileSize") && long.TryParse(evt.Data["doFileSize"]?.ToString(), out var doFs))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, doFs);
if (evt.Data.ContainsKey("doBytesFromPeers") && long.TryParse(evt.Data["doBytesFromPeers"]?.ToString(), out var doPeers))
summary.DoBytesFromPeers = doPeers;
if (evt.Data.ContainsKey("doBytesFromHttp") && long.TryParse(evt.Data["doBytesFromHttp"]?.ToString(), out var doHttp))
summary.DoBytesFromHttp = doHttp;
if (evt.Data.ContainsKey("doPercentPeerCaching") && int.TryParse(evt.Data["doPercentPeerCaching"]?.ToString(), out var doPct))
summary.DoPercentPeerCaching = doPct;
if (evt.Data.ContainsKey("doDownloadMode") && int.TryParse(evt.Data["doDownloadMode"]?.ToString(), out var doMode))
summary.DoDownloadMode = doMode;
if (evt.Data.ContainsKey("doDownloadDuration"))
{
var doDurStr = evt.Data["doDownloadDuration"]?.ToString() ?? string.Empty;
summary.DoDownloadDuration = doDurStr;
// Use DO's measured duration for throughput calculation (more accurate than timestamp diff)
if (TimeSpan.TryParse(doDurStr, out var doDurTs) && doDurTs.TotalSeconds >= 1)
summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, (int)doDurTs.TotalSeconds);
}
if (evt.Data.ContainsKey("doBytesFromLanPeers") && long.TryParse(evt.Data["doBytesFromLanPeers"]?.ToString(), out var doLan))
summary.DoBytesFromLanPeers = doLan;
if (evt.Data.ContainsKey("doBytesFromGroupPeers") && long.TryParse(evt.Data["doBytesFromGroupPeers"]?.ToString(), out var doGroup))
summary.DoBytesFromGroupPeers = doGroup;
if (evt.Data.ContainsKey("doBytesFromInternetPeers") && long.TryParse(evt.Data["doBytesFromInternetPeers"]?.ToString(), out var doInet))
summary.DoBytesFromInternetPeers = doInet;
}
break;
}
RecalculateAppDurations(state);
}
private static void RecalculateAppDurations(AppInstallAggregationState state)
{
var summary = state.Summary;
// Effective start for full app duration: earliest known install/download start.
var effectiveStart = summary.StartedAt;
if (state.DownloadStartedAt.HasValue &&
(effectiveStart == DateTime.MinValue || state.DownloadStartedAt.Value < effectiveStart))
{
effectiveStart = state.DownloadStartedAt.Value;
}
if (state.InstallStartedAt.HasValue &&
(effectiveStart == DateTime.MinValue || state.InstallStartedAt.Value < effectiveStart))
{
effectiveStart = state.InstallStartedAt.Value;
}
if (effectiveStart != DateTime.MinValue)
{
summary.StartedAt = effectiveStart;
}
// Download duration: from first download start to first install start.
if (state.DownloadStartedAt.HasValue && state.InstallStartedAt.HasValue &&
state.InstallStartedAt.Value >= state.DownloadStartedAt.Value)
{
summary.DownloadDurationSeconds = (int)(state.InstallStartedAt.Value - state.DownloadStartedAt.Value).TotalSeconds;
}
// Full duration: from effective start to completion/failure.
if (summary.CompletedAt.HasValue && summary.StartedAt != DateTime.MinValue &&
summary.CompletedAt.Value >= summary.StartedAt)
{
summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds;
}
}
private async Task<IngestEventsOutput> CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message)
{
var response = req.CreateResponse(statusCode);
var errorResponse = new IngestEventsResponse
{
Success = false,
EventsReceived = 0,
EventsProcessed = 0,
Message = message,
ProcessedAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(errorResponse);
return new IngestEventsOutput { HttpResponse = response, SignalRMessages = Array.Empty<SignalRMessageAction>() };
}
}
}
@@ -0,0 +1,539 @@
using System.Linq;
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Ingest
{
/// <summary>
/// Partial: Core ingest processing logic — event classification, session updates,
/// rule analysis, Teams notifications, and SignalR message construction.
/// </summary>
public partial class IngestEventsFunction
{
/// <summary>
/// Core ingest logic: device block check, NDJSON parsing, event storage, rule engine, SignalR.
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
/// </summary>
internal async Task<IngestEventsOutput> ProcessIngestAsync(HttpRequestData req, string tenantId, SecurityValidationResult validation)
{
// --- Device block check (after security, before body decompression) ---
// Check if this device has been administratively blocked (e.g. rogue device sending excessive data).
// We read the serial number from the header (same header used by AutopilotDeviceValidator).
// Using HTTP 200 with DeviceBlocked=true so the agent does not trigger its auth-failure circuit breaker.
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
if (!string.IsNullOrEmpty(serialNumberHeader))
{
var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader);
if (isBlocked)
{
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt);
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
{
Success = false,
DeviceBlocked = true,
DeviceKillSignal = isKill,
UnblockAt = unblockAt,
Message = isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.",
ProcessedAt = DateTime.UtcNow
});
return new IngestEventsOutput
{
HttpResponse = blockedHttpResponse,
SignalRMessages = Array.Empty<SignalRMessageAction>()
};
}
}
// --- Parse NDJSON+gzip request body (only after security is cleared) ---
var request = await ParseNdjsonGzipRequest(req.Body, tenantId);
if (request?.Events == null || request.Events.Count == 0)
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "No events provided");
}
if (string.IsNullOrEmpty(request.SessionId) || string.IsNullOrEmpty(request.TenantId))
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required");
}
// Ensure body TenantId matches the validated header TenantId (prevent body spoofing)
if (!string.Equals(request.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("TenantId mismatch: header={HeaderTenantId}, body={BodyTenantId}", tenantId, request.TenantId);
return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload");
}
var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]";
_logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})");
// Store events in Azure Table Storage (batch write for efficiency)
var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events);
int processedCount = storedEvents.Count;
// Classify events for downstream processing
var classification = ClassifyEvents(storedEvents);
// Store app install summaries
foreach (var summary in classification.AppInstallUpdates.Values)
{
await _storageService.StoreAppInstallSummaryAsync(summary.Summary);
}
// Extract geo-location data and merge into session row
if (classification.DeviceLocationEvent?.Data != null)
{
var geoData = classification.DeviceLocationEvent.Data;
await _storageService.UpdateSessionGeoAsync(
request.TenantId,
request.SessionId,
geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null,
geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null,
geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null,
geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null
);
}
// Update session status based on events
var (statusTransitioned, whiteGloveStatusTransitioned, failureReason) = await UpdateSessionStatusAsync(
request, sessionPrefix, classification);
// Always increment event count when events were stored
if (processedCount > 0)
{
await _storageService.IncrementSessionEventCountAsync(
request.TenantId,
request.SessionId,
processedCount,
classification.EarliestEventTimestamp,
classification.LatestEventTimestamp,
currentPhase: classification.LastPhaseChangeEvent?.Phase
);
}
// Run full analysis when enrollment ends (cost-efficient: one pass over all events)
var newRuleResults = new List<AutopilotMonitor.Shared.Models.RuleResult>();
if (classification.CompletionEvent != null || classification.FailureEvent != null)
{
try
{
var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger);
newRuleResults = await ruleEngine.AnalyzeSessionAsync(request.TenantId, request.SessionId);
foreach (var result in newRuleResults)
{
await _storageService.StoreRuleResultAsync(result);
}
if (newRuleResults.Count > 0)
{
_logger.LogInformation($"{sessionPrefix} Enrollment-end analysis: {newRuleResults.Count} issue(s) detected");
}
}
catch (Exception ruleEx)
{
_logger.LogWarning(ruleEx, $"{sessionPrefix} Enrollment-end analysis failed (non-fatal)");
}
}
// Increment platform stats (fire-and-forget, non-blocking)
_ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
if (newRuleResults.Count > 0)
_ = _storageService.IncrementPlatformStatAsync("IssuesDetected", newRuleResults.Count)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
if (classification.CompletionEvent != null)
_ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments")
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
// Store diagnostics blob name on session (if agent uploaded a diagnostics package)
if (classification.DiagnosticsUploadedEvent != null)
{
var blobName = classification.DiagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true
? classification.DiagnosticsUploadedEvent.Data["blobName"]?.ToString()
: null;
if (!string.IsNullOrEmpty(blobName))
{
await _storageService.UpdateSessionDiagnosticsBlobAsync(
request.TenantId, request.SessionId, blobName);
}
}
// Retrieve updated session data to include in SignalR messages
var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId);
// Session age warning: log if session >4h old and still InProgress (observability only)
if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress)
{
var sessionAge = DateTime.UtcNow - updatedSession.StartedAt;
if (sessionAge.TotalHours > 4)
{
_logger.LogWarning("Session {SessionId} (tenant {TenantId}) still InProgress after {Hours:F1}h — may be stuck",
request.SessionId, request.TenantId, sessionAge.TotalHours);
}
}
// Log warning if WhiteGlove status update was not persisted despite retries and fallback.
if (classification.WhiteGloveEvent != null && updatedSession?.IsPreProvisioned != true)
{
_logger.LogError(
"{SessionPrefix} WhiteGlove status update not persisted after retries and fallback. " +
"IsPreProvisioned={IsPreProvisioned}, Status={Status}. " +
"Proceeding with 200 to allow agent spool drain.",
sessionPrefix, updatedSession?.IsPreProvisioned, updatedSession?.Status);
}
// Send Teams notifications (fire-and-forget, non-fatal)
await SendTeamsNotificationsAsync(
request, sessionPrefix, classification, updatedSession,
statusTransitioned, whiteGloveStatusTransitioned, failureReason);
// Build HTTP response + SignalR messages
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new IngestEventsResponse
{
Success = true,
EventsReceived = request.Events.Count,
EventsProcessed = processedCount,
Message = $"Successfully stored {processedCount} of {request.Events.Count} events",
ProcessedAt = DateTime.UtcNow
});
var signalRMessages = BuildSignalRMessages(request, updatedSession, processedCount, newRuleResults);
return new IngestEventsOutput
{
HttpResponse = response,
SignalRMessages = signalRMessages
};
}
/// <summary>
/// Classifies stored events by type for downstream processing.
/// </summary>
private EventClassification ClassifyEvents(List<EnrollmentEvent> storedEvents)
{
var classification = new EventClassification();
foreach (var evt in storedEvents)
{
if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value)
classification.EarliestEventTimestamp = evt.Timestamp;
if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value)
classification.LatestEventTimestamp = evt.Timestamp;
switch (evt.EventType)
{
case "phase_changed":
case "esp_phase_changed":
classification.LastPhaseChangeEvent = evt;
break;
case "enrollment_complete":
classification.CompletionEvent = evt;
break;
case "gather_rules_collection_completed":
classification.GatherCompletionEvent = evt;
break;
case "enrollment_failed":
classification.FailureEvent = evt;
break;
case "diagnostics_uploaded":
classification.DiagnosticsUploadedEvent = evt;
break;
case "whiteglove_complete":
classification.WhiteGloveEvent = evt;
break;
case "whiteglove_resumed":
classification.WhiteGloveResumedEvent = evt;
break;
case "esp_failure":
classification.EspFailureEvent = evt;
break;
case "device_location":
classification.DeviceLocationEvent = evt;
break;
}
AggregateAppInstallEvent(evt, storedEvents[0].TenantId, storedEvents[0].SessionId, classification.AppInstallUpdates);
}
return classification;
}
/// <summary>
/// Updates session status based on classified events.
/// Returns (statusTransitioned, whiteGloveStatusTransitioned, failureReason).
/// </summary>
private async Task<(bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason)> UpdateSessionStatusAsync(
IngestEventsRequest request, string sessionPrefix, EventClassification c)
{
bool statusTransitioned = false;
bool whiteGloveStatusTransitioned = false;
string? failureReason = null;
if (c.CompletionEvent != null)
{
statusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase,
completedAt: c.CompletionEvent.Timestamp,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp);
_logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned);
}
else if (c.FailureEvent != null)
{
failureReason = c.FailureEvent.Data?.ContainsKey("errorCode") == true
? $"{c.FailureEvent.Message} ({c.FailureEvent.Data["errorCode"]})"
: c.FailureEvent.Message;
statusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Failed, c.FailureEvent.Phase, failureReason,
completedAt: c.FailureEvent.Timestamp,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp);
_logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned);
}
else if (c.EspFailureEvent != null)
{
failureReason = c.EspFailureEvent.Message ?? "ESP failure (backend fallback)";
statusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Failed, c.EspFailureEvent.Phase, failureReason,
completedAt: c.EspFailureEvent.Timestamp,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp);
_logger.LogWarning("{SessionPrefix} Status: Failed via esp_failure fallback - {FailureReason} (transitioned={Transitioned})",
sessionPrefix, failureReason, statusTransitioned);
}
else if (c.GatherCompletionEvent != null)
{
await _storageService.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Succeeded, c.GatherCompletionEvent.Phase,
completedAt: c.GatherCompletionEvent.Timestamp,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp);
_logger.LogInformation("{SessionPrefix} Status: Succeeded (gather_rules)", sessionPrefix);
}
else if (c.WhiteGloveEvent != null)
{
whiteGloveStatusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Pending, c.WhiteGloveEvent.Phase,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
isPreProvisioned: true, isUserDriven: false);
if (!whiteGloveStatusTransitioned)
{
_logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix);
try
{
await _storageService.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false);
whiteGloveStatusTransitioned = true;
_logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix);
}
catch (Exception fallbackEx)
{
_logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix);
}
}
_logger.LogInformation("{SessionPrefix} Status: Pending (WhiteGlove pre-provisioning complete, transitioned={Transitioned})", sessionPrefix, whiteGloveStatusTransitioned);
}
else if (c.WhiteGloveResumedEvent != null)
{
var currentSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId);
if (currentSession?.Status == SessionStatus.Pending)
{
await _storageService.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.InProgress, c.WhiteGloveResumedEvent.Phase,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
isUserDriven: true, resumedAt: c.WhiteGloveResumedEvent.Timestamp);
_logger.LogInformation("{SessionPrefix} Status: InProgress (WhiteGlove Part 2 resumed, IsUserDriven=true)", sessionPrefix);
}
else
{
_logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status);
}
}
return (statusTransitioned, whiteGloveStatusTransitioned, failureReason);
}
/// <summary>
/// Sends Teams notifications for enrollment completion, WhiteGlove, and ESP failure events.
/// </summary>
private async Task SendTeamsNotificationsAsync(
IngestEventsRequest request, string sessionPrefix, EventClassification c,
SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason)
{
// Send Teams notification on enrollment completion (fire-and-forget, non-fatal)
// Only send when statusTransitioned=true to prevent duplicates on retry/double-upload
if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null))
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl))
{
var notifySuccess = c.CompletionEvent != null && tenantConfig.TeamsNotifyOnSuccess;
var notifyFailure = c.FailureEvent != null && tenantConfig.TeamsNotifyOnFailure;
if (notifySuccess || notifyFailure)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
// For WhiteGlove sessions: show user enrollment duration only (Duration 2)
if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null)
{
var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp;
if (completionTime.HasValue)
duration = completionTime.Value - updatedSession.ResumedAt.Value;
}
_ = _teamsNotificationService.SendEnrollmentNotificationAsync(
tenantConfig.TeamsWebhookUrl,
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: c.CompletionEvent != null,
failureReason: failureReason,
duration: duration);
}
}
}
// Send Teams notification on WhiteGlove pre-provisioning completion
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null)
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnSuccess)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
_ = _teamsNotificationService.SendWhiteGloveNotificationAsync(
tenantConfig.TeamsWebhookUrl,
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: true,
duration: duration);
}
}
// Send Teams notification on WhiteGlove pre-provisioning failure via esp_failure
if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true)
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnFailure)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
_ = _teamsNotificationService.SendWhiteGloveNotificationAsync(
tenantConfig.TeamsWebhookUrl,
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: false,
duration: duration);
}
}
}
/// <summary>
/// Builds SignalR messages for tenant-level and session-level real-time updates.
/// </summary>
private SignalRMessageAction[] BuildSignalRMessages(
IngestEventsRequest request, SessionSummary? updatedSession, int processedCount,
List<AutopilotMonitor.Shared.Models.RuleResult> newRuleResults)
{
// 1. Summary notification for session list updates (tenant-specific only)
// Send only mutable fields as delta update
object? sessionDelta = updatedSession != null ? new {
updatedSession.CurrentPhase,
updatedSession.CurrentPhaseDetail,
updatedSession.Status,
updatedSession.FailureReason,
updatedSession.EventCount,
updatedSession.DurationSeconds,
updatedSession.CompletedAt,
updatedSession.DiagnosticsBlobName,
updatedSession.IsPreProvisioned
} : null;
var summaryMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{request.TenantId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
eventCount = processedCount,
sessionUpdate = sessionDelta
} }
};
// 2. Signal for real-time event streaming on detail pages (session-specific)
var slimRuleResults = newRuleResults.Count > 0
? newRuleResults.Select(r => new {
r.ResultId,
r.RuleId,
r.RuleTitle,
r.Severity,
r.Category,
r.ConfidenceScore,
r.Explanation,
r.Remediation,
r.RelatedDocs,
r.MatchedConditions,
r.DetectedAt
}).ToList<object>()
: null;
var eventsMessage = new SignalRMessageAction("eventStream")
{
GroupName = $"session-{request.TenantId}-{request.SessionId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
newEventCount = processedCount,
newRuleResults = slimRuleResults
} }
};
return new[] { summaryMessage, eventsMessage };
}
}
/// <summary>
/// Holds classified events from an ingest batch for downstream processing.
/// </summary>
internal class EventClassification
{
public EnrollmentEvent? LastPhaseChangeEvent { get; set; }
public EnrollmentEvent? CompletionEvent { get; set; }
public EnrollmentEvent? FailureEvent { get; set; }
public EnrollmentEvent? GatherCompletionEvent { get; set; }
public EnrollmentEvent? DiagnosticsUploadedEvent { get; set; }
public EnrollmentEvent? WhiteGloveEvent { get; set; }
public EnrollmentEvent? WhiteGloveResumedEvent { get; set; }
public EnrollmentEvent? EspFailureEvent { get; set; }
public EnrollmentEvent? DeviceLocationEvent { get; set; }
public DateTime? EarliestEventTimestamp { get; set; }
public DateTime? LatestEventTimestamp { get; set; }
public Dictionary<string, AppInstallAggregationState> AppInstallUpdates { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}
@@ -1,7 +1,4 @@
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Text;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
@@ -9,12 +6,10 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Ingest
{
public class IngestEventsFunction
public partial class IngestEventsFunction
{
private readonly ILogger<IngestEventsFunction> _logger;
private readonly TableStorageService _storageService;
@@ -100,851 +95,6 @@ namespace AutopilotMonitor.Functions.Functions
return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error");
}
}
/// <summary>
/// Core ingest logic: device block check, NDJSON parsing, event storage, rule engine, SignalR.
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
/// </summary>
internal async Task<IngestEventsOutput> ProcessIngestAsync(HttpRequestData req, string tenantId, SecurityValidationResult validation)
{
// --- Device block check (after security, before body decompression) ---
// Check if this device has been administratively blocked (e.g. rogue device sending excessive data).
// We read the serial number from the header (same header used by AutopilotDeviceValidator).
// Using HTTP 200 with DeviceBlocked=true so the agent does not trigger its auth-failure circuit breaker.
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
if (!string.IsNullOrEmpty(serialNumberHeader))
{
var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader);
if (isBlocked)
{
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt);
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
{
Success = false,
DeviceBlocked = true,
DeviceKillSignal = isKill,
UnblockAt = unblockAt,
Message = isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.",
ProcessedAt = DateTime.UtcNow
});
return new IngestEventsOutput
{
HttpResponse = blockedHttpResponse,
SignalRMessages = Array.Empty<SignalRMessageAction>()
};
}
}
// --- Parse NDJSON+gzip request body (only after security is cleared) ---
var request = await ParseNdjsonGzipRequest(req.Body, tenantId);
if (request?.Events == null || request.Events.Count == 0)
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "No events provided");
}
if (string.IsNullOrEmpty(request.SessionId) || string.IsNullOrEmpty(request.TenantId))
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required");
}
// Ensure body TenantId matches the validated header TenantId (prevent body spoofing)
if (!string.Equals(request.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("TenantId mismatch: header={HeaderTenantId}, body={BodyTenantId}", tenantId, request.TenantId);
return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload");
}
var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]";
_logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})");
// Store events in Azure Table Storage (batch write for efficiency)
var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events);
int processedCount = storedEvents.Count;
EnrollmentEvent? lastPhaseChangeEvent = null;
EnrollmentEvent? completionEvent = null;
EnrollmentEvent? failureEvent = null;
EnrollmentEvent? gatherCompletionEvent = null;
EnrollmentEvent? diagnosticsUploadedEvent = null;
EnrollmentEvent? whiteGloveEvent = null;
EnrollmentEvent? whiteGloveResumedEvent = null;
EnrollmentEvent? espFailureEvent = null;
EnrollmentEvent? deviceLocationEvent = null;
string? failureReason = null;
DateTime? earliestEventTimestamp = null;
DateTime? latestEventTimestamp = null;
// Track app install events for AppInstallSummary aggregation
var appInstallUpdates = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
foreach (var evt in storedEvents)
{
// Track earliest event timestamp for accurate session StartedAt
if (!earliestEventTimestamp.HasValue || evt.Timestamp < earliestEventTimestamp.Value)
{
earliestEventTimestamp = evt.Timestamp;
}
// Track latest event timestamp for excessive data sender detection
if (!latestEventTimestamp.HasValue || evt.Timestamp > latestEventTimestamp.Value)
{
latestEventTimestamp = evt.Timestamp;
}
// Track special events for session status updates
if (evt.EventType == "phase_changed" || evt.EventType == "esp_phase_changed")
{
lastPhaseChangeEvent = evt;
}
else if (evt.EventType == "enrollment_complete")
{
completionEvent = evt;
}
else if (evt.EventType == "gather_rules_collection_completed")
{
gatherCompletionEvent = evt;
}
else if (evt.EventType == "enrollment_failed")
{
failureEvent = evt;
}
else if (evt.EventType == "diagnostics_uploaded")
{
diagnosticsUploadedEvent = evt;
}
else if (evt.EventType == "whiteglove_complete")
{
whiteGloveEvent = evt;
}
else if (evt.EventType == "whiteglove_resumed")
{
whiteGloveResumedEvent = evt;
}
else if (evt.EventType == "esp_failure")
{
espFailureEvent = evt;
}
else if (evt.EventType == "device_location")
{
deviceLocationEvent = evt;
}
// Track app install events for per-app metrics
AggregateAppInstallEvent(evt, request.TenantId, request.SessionId, appInstallUpdates);
}
// Store app install summaries
foreach (var summary in appInstallUpdates.Values)
{
await _storageService.StoreAppInstallSummaryAsync(summary.Summary);
}
// Extract geo-location data and merge into session row
if (deviceLocationEvent?.Data != null)
{
var geoData = deviceLocationEvent.Data;
await _storageService.UpdateSessionGeoAsync(
request.TenantId,
request.SessionId,
geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null,
geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null,
geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null,
geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null
);
}
// Update session status based on events
// statusTransitioned = true only when this is the FIRST time the session reaches Succeeded/Failed
// (UpdateSessionStatusAsync returns false if already in a terminal state — idempotency guard)
bool statusTransitioned = false;
bool whiteGloveStatusTransitioned = false;
if (completionEvent != null)
{
// Enrollment completed successfully - use event timestamp for accurate duration
statusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Succeeded,
completionEvent.Phase,
completedAt: completionEvent.Timestamp,
earliestEventTimestamp: earliestEventTimestamp,
latestEventTimestamp: latestEventTimestamp
);
_logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned);
}
else if (failureEvent != null)
{
// Enrollment failed - use event timestamp for accurate duration
failureReason = failureEvent.Data?.ContainsKey("errorCode") == true
? $"{failureEvent.Message} ({failureEvent.Data["errorCode"]})"
: failureEvent.Message;
statusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Failed,
failureEvent.Phase,
failureReason,
completedAt: failureEvent.Timestamp,
earliestEventTimestamp: earliestEventTimestamp,
latestEventTimestamp: latestEventTimestamp
);
_logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned);
}
else if (espFailureEvent != null)
{
// Fallback: esp_failure arrived without enrollment_failed —
// agent-side handler likely threw an exception (catch {} swallowed it).
failureReason = espFailureEvent.Message ?? "ESP failure (backend fallback)";
statusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Failed,
espFailureEvent.Phase,
failureReason,
completedAt: espFailureEvent.Timestamp,
earliestEventTimestamp: earliestEventTimestamp,
latestEventTimestamp: latestEventTimestamp
);
_logger.LogWarning("{SessionPrefix} Status: Failed via esp_failure fallback - {FailureReason} (transitioned={Transitioned})",
sessionPrefix, failureReason, statusTransitioned);
}
else if (gatherCompletionEvent != null)
{
// Gather rules collection completed — mark session as Succeeded.
// Does not trigger enrollment stats or Teams notifications (not a real enrollment).
await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Succeeded,
gatherCompletionEvent.Phase,
completedAt: gatherCompletionEvent.Timestamp,
earliestEventTimestamp: earliestEventTimestamp,
latestEventTimestamp: latestEventTimestamp
);
_logger.LogInformation("{SessionPrefix} Status: Succeeded (gather_rules)", sessionPrefix);
}
else if (whiteGloveEvent != null)
{
// WhiteGlove pre-provisioning completed — transition to Pending
// IsPreProvisioned is set atomically in the same merge write to prevent
// the race condition where status update fails but IsPreProvisioned succeeds.
whiteGloveStatusTransitioned = await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Pending,
whiteGloveEvent.Phase,
earliestEventTimestamp: earliestEventTimestamp,
latestEventTimestamp: latestEventTimestamp,
isPreProvisioned: true,
isUserDriven: false
);
// Fallback: if the optimistic-concurrency merge failed despite internal retries
// (5 ETag retries + force-write), use an unconditional write to persist both
// IsPreProvisioned and Status=Pending. UpdateSessionStatusAsync only retries
// on ETag conflicts (412); any other transient error (429/503/timeout) causes
// an immediate return false — this fallback bypasses that limitation.
if (!whiteGloveStatusTransitioned)
{
_logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix);
try
{
await _storageService.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false);
whiteGloveStatusTransitioned = true;
_logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix);
}
catch (Exception fallbackEx)
{
_logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix);
}
}
_logger.LogInformation("{SessionPrefix} Status: Pending (WhiteGlove pre-provisioning complete, transitioned={Transitioned})", sessionPrefix, whiteGloveStatusTransitioned);
}
else if (whiteGloveResumedEvent != null)
{
// WhiteGlove Part 2: agent signals that user enrollment started after
// pre-provisioning. Transition Pending → InProgress only if the session
// is actually in Pending state — prevents overwriting Succeeded/Failed
// if the event arrives late.
var currentSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId);
if (currentSession?.Status == SessionStatus.Pending)
{
await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.InProgress,
whiteGloveResumedEvent.Phase,
earliestEventTimestamp: earliestEventTimestamp,
latestEventTimestamp: latestEventTimestamp,
isUserDriven: true,
resumedAt: whiteGloveResumedEvent.Timestamp
);
_logger.LogInformation("{SessionPrefix} Status: InProgress (WhiteGlove Part 2 resumed, IsUserDriven=true)", sessionPrefix);
}
else
{
_logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status);
}
}
// Always increment event count when events were stored, regardless of which
// status branch was taken above. UpdateSessionStatusAsync uses Merge-mode and
// does not touch EventCount, so this separate call is needed to keep the count
// accurate. Both methods have ETag retry logic; the second write retries on conflict.
if (processedCount > 0)
{
await _storageService.IncrementSessionEventCountAsync(
request.TenantId,
request.SessionId,
processedCount,
earliestEventTimestamp,
latestEventTimestamp,
currentPhase: lastPhaseChangeEvent?.Phase
);
}
// Run full analysis when enrollment ends (cost-efficient: one pass over all events)
var newRuleResults = new List<AutopilotMonitor.Shared.Models.RuleResult>();
if (completionEvent != null || failureEvent != null)
{
try
{
var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger);
newRuleResults = await ruleEngine.AnalyzeSessionAsync(request.TenantId, request.SessionId);
foreach (var result in newRuleResults)
{
await _storageService.StoreRuleResultAsync(result);
}
if (newRuleResults.Count > 0)
{
_logger.LogInformation($"{sessionPrefix} Enrollment-end analysis: {newRuleResults.Count} issue(s) detected");
}
}
catch (Exception ruleEx)
{
_logger.LogWarning(ruleEx, $"{sessionPrefix} Enrollment-end analysis failed (non-fatal)");
}
}
// Increment platform stats (fire-and-forget, non-blocking)
_ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
if (newRuleResults.Count > 0)
_ = _storageService.IncrementPlatformStatAsync("IssuesDetected", newRuleResults.Count)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
if (completionEvent != null)
_ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments")
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
// Store diagnostics blob name on session (if agent uploaded a diagnostics package)
if (diagnosticsUploadedEvent != null)
{
var blobName = diagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true
? diagnosticsUploadedEvent.Data["blobName"]?.ToString()
: null;
if (!string.IsNullOrEmpty(blobName))
{
await _storageService.UpdateSessionDiagnosticsBlobAsync(
request.TenantId, request.SessionId, blobName);
}
}
// Retrieve updated session data to include in SignalR messages
var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId);
// Session age warning: log if session >4h old and still InProgress (observability only)
if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress)
{
var sessionAge = DateTime.UtcNow - updatedSession.StartedAt;
if (sessionAge.TotalHours > 4)
{
_logger.LogWarning("Session {SessionId} (tenant {TenantId}) still InProgress after {Hours:F1}h — may be stuck",
request.SessionId, request.TenantId, sessionAge.TotalHours);
}
}
// Log warning if WhiteGlove status update was not persisted despite retries and fallback.
// Events are already stored (idempotent UpsertReplace). Do NOT return 500 here:
// the agent retries the same batch containing whiteglove_complete, hitting the same
// failing update path, creating a self-reinforcing 500 loop that blocks spool drain
// and causes data loss on the monitoring session. Returning 200 lets the agent
// drain its spool; the session may need manual correction in the worst case.
if (whiteGloveEvent != null && updatedSession?.IsPreProvisioned != true)
{
_logger.LogError(
"{SessionPrefix} WhiteGlove status update not persisted after retries and fallback. " +
"IsPreProvisioned={IsPreProvisioned}, Status={Status}. " +
"Proceeding with 200 to allow agent spool drain.",
sessionPrefix, updatedSession?.IsPreProvisioned, updatedSession?.Status);
}
// Send Teams notification on enrollment completion (fire-and-forget, non-fatal)
// Only send when statusTransitioned=true to prevent duplicates on retry/double-upload
if (statusTransitioned && (completionEvent != null || failureEvent != null))
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl))
{
var notifySuccess = completionEvent != null && tenantConfig.TeamsNotifyOnSuccess;
var notifyFailure = failureEvent != null && tenantConfig.TeamsNotifyOnFailure;
if (notifySuccess || notifyFailure)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
// For WhiteGlove sessions: show user enrollment duration only (Duration 2)
// instead of the full wall-clock time which includes the pre-provisioning pause.
if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null)
{
var completionTime = completionEvent?.Timestamp ?? failureEvent?.Timestamp;
if (completionTime.HasValue)
duration = completionTime.Value - updatedSession.ResumedAt.Value;
}
_ = _teamsNotificationService.SendEnrollmentNotificationAsync(
tenantConfig.TeamsWebhookUrl,
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: completionEvent != null,
failureReason: failureReason,
duration: duration);
}
}
}
// Send Teams notification on WhiteGlove pre-provisioning completion (fire-and-forget, non-fatal)
// Only send when whiteGloveStatusTransitioned=true to prevent duplicates on retry/double-upload
if (whiteGloveStatusTransitioned && whiteGloveEvent != null)
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnSuccess)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
_ = _teamsNotificationService.SendWhiteGloveNotificationAsync(
tenantConfig.TeamsWebhookUrl,
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: true,
duration: duration);
}
}
// Send Teams notification on WhiteGlove pre-provisioning failure via esp_failure (fire-and-forget, non-fatal)
// Only notify if the session is already pre-provisioned (IsPreProvisioned=true), meaning we are in a
// WhiteGlove scenario. For normal enrollments, esp_failure is handled via enrollment_failed.
if (espFailureEvent != null && updatedSession?.IsPreProvisioned == true)
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnFailure)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
_ = _teamsNotificationService.SendWhiteGloveNotificationAsync(
tenantConfig.TeamsWebhookUrl,
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: false,
duration: duration);
}
}
var response = req.CreateResponse(HttpStatusCode.OK);
var responseData = new IngestEventsResponse
{
Success = true,
EventsReceived = request.Events.Count,
EventsProcessed = processedCount,
Message = $"Successfully stored {processedCount} of {request.Events.Count} events",
ProcessedAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(responseData);
// Send SignalR notifications using Groups for multi-tenancy and cost optimization
// Include session data so frontend doesn't need to fetch it (cost optimization)
// 1. Summary notification for session list updates (tenant-specific only)
// Only sent to clients in the tenant group - prevents cross-tenant data leaks
// Galactic Admins only receive "newSession" events from RegisterSessionFunction,
// not every event batch, to avoid flooding them with updates
// Note: newRuleResults intentionally omitted - list views don't use them
// Send only mutable fields as delta update (static fields like
// serialNumber, deviceName, manufacturer, model, startedAt,
// enrollmentType never change after registration)
object? sessionDelta = updatedSession != null ? new {
updatedSession.CurrentPhase,
updatedSession.CurrentPhaseDetail,
updatedSession.Status,
updatedSession.FailureReason,
updatedSession.EventCount,
updatedSession.DurationSeconds,
updatedSession.CompletedAt,
updatedSession.DiagnosticsBlobName,
updatedSession.IsPreProvisioned
} : null;
var summaryMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{request.TenantId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
eventCount = processedCount,
sessionUpdate = sessionDelta
} }
};
// 2. Signal for real-time event streaming on detail pages (session-specific)
// Only sent to clients viewing this specific session - cost-efficient signal pattern:
// frontend fetches full events from Table Storage on receipt, ensuring canonical truth
// and eliminating SignalR gap issues. Events payload omitted intentionally.
var slimRuleResults = newRuleResults.Count > 0
? newRuleResults.Select(r => new {
r.ResultId,
r.RuleId,
r.RuleTitle,
r.Severity,
r.Category,
r.ConfidenceScore,
r.Explanation,
r.Remediation,
r.RelatedDocs,
r.MatchedConditions,
r.DetectedAt
}).ToList<object>()
: null;
// Signal-only pattern: no full session object — the detail page already receives
// the session delta via the "newevents" message (tenant group) and fetches fresh
// events from Table Storage on receipt. Sending the full session here was redundant.
var eventsMessage = new SignalRMessageAction("eventStream")
{
GroupName = $"session-{request.TenantId}-{request.SessionId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
newEventCount = processedCount,
newRuleResults = slimRuleResults
} }
};
return new IngestEventsOutput
{
HttpResponse = response,
SignalRMessages = new[] { summaryMessage, eventsMessage }
};
}
/// <summary>
/// Parses NDJSON + gzip compressed request
/// Format: First line is metadata (sessionId, tenantId), subsequent lines are events
/// </summary>
private async Task<IngestEventsRequest> ParseNdjsonGzipRequest(Stream body, string? tenantId = null)
{
// Get configuration for payload size limit (use default if tenantId not available yet)
var config = !string.IsNullOrEmpty(tenantId)
? await _configService.GetConfigurationAsync(tenantId)
: null;
var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024;
// Decompress gzip with size limit protection
using var decompressed = new MemoryStream();
using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true))
{
// Copy with size limit to prevent memory exhaustion attacks
var buffer = new byte[8192];
int bytesRead;
long totalBytesRead = 0;
while ((bytesRead = await gzip.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
totalBytesRead += bytesRead;
// Check if we've exceeded the maximum payload size
if (totalBytesRead > maxPayloadSizeBytes)
{
throw new InvalidOperationException(
$"NDJSON payload size exceeds maximum allowed size of {config?.MaxNdjsonPayloadSizeMB ?? 5} MB (decompressed). " +
$"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB"
);
}
await decompressed.WriteAsync(buffer, 0, bytesRead);
}
_logger.LogDebug($"NDJSON payload decompressed: {totalBytesRead / 1024.0:F2} KB (limit: {maxPayloadSizeBytes / 1024.0 / 1024.0} MB)");
}
decompressed.Position = 0;
var ndjson = await new StreamReader(decompressed, Encoding.UTF8).ReadToEndAsync();
// Parse NDJSON (newline-delimited JSON)
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 1)
{
throw new InvalidOperationException("NDJSON must contain at least metadata line");
}
// First line: metadata
var metadata = JsonConvert.DeserializeObject<NdjsonMetadata>(lines[0]);
if (metadata == null)
{
throw new InvalidOperationException("Failed to parse NDJSON metadata");
}
// Subsequent lines: events
var events = new List<EnrollmentEvent>();
for (int i = 1; i < lines.Length; i++)
{
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(lines[i]);
if (evt != null)
{
NormalizeEventData(evt);
events.Add(evt);
}
}
return new IngestEventsRequest
{
SessionId = metadata.SessionId,
TenantId = metadata.TenantId,
Events = events
};
}
/// <summary>
/// Normalizes event Data dictionary by converting Newtonsoft JToken objects to native .NET types.
/// Required because Newtonsoft.Json deserializes nested objects as JObject/JArray, which
/// System.Text.Json (used by SignalR) cannot serialize correctly - producing [[[]]] instead of real values.
/// </summary>
private static void NormalizeEventData(EnrollmentEvent evt)
{
if (evt.Data == null || evt.Data.Count == 0) return;
var normalized = new Dictionary<string, object>();
foreach (var kvp in evt.Data)
normalized[kvp.Key] = ConvertJTokenToNative(kvp.Value);
evt.Data = normalized;
}
private static object ConvertJTokenToNative(object value)
{
if (value is JArray jArray)
return jArray.Select(item => ConvertJTokenToNative(item)).ToList<object>();
if (value is JObject jObject)
{
var dict = new Dictionary<string, object>();
foreach (var prop in jObject.Properties())
dict[prop.Name] = ConvertJTokenToNative(prop.Value);
return dict;
}
if (value is JValue jValue)
return jValue.Value ?? string.Empty;
return value;
}
/// <summary>
/// Aggregates app install events into AppInstallSummary records
/// </summary>
private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallAggregationState> summaries)
{
// Agent sends: app_install_started, app_install_completed, app_install_failed, app_download_started, download_progress
// Support both legacy (app_install_start/complete) and current agent event types
bool isRelevant =
evt.EventType == "app_install_started" || evt.EventType == "app_install_start" ||
evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" ||
evt.EventType == "app_install_failed" ||
evt.EventType == "app_download_started" ||
evt.EventType == "app_install_skipped" ||
evt.EventType == "download_progress" ||
evt.EventType == "do_telemetry";
if (!isRelevant) return;
var appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null;
if (string.IsNullOrEmpty(appName)) return;
if (!summaries.TryGetValue(appName, out var state))
{
state = new AppInstallAggregationState
{
Summary = new AppInstallSummary
{
AppName = appName,
SessionId = sessionId,
TenantId = tenantId,
StartedAt = evt.Timestamp
}
};
summaries[appName] = state;
}
var summary = state.Summary;
switch (evt.EventType)
{
case "app_install_started":
case "app_install_start":
if (!state.InstallStartedAt.HasValue || evt.Timestamp < state.InstallStartedAt.Value)
state.InstallStartedAt = evt.Timestamp;
if (summary.Status == "InProgress" || summary.Status == string.Empty)
summary.Status = "InProgress";
break;
case "app_download_started":
if (!state.DownloadStartedAt.HasValue || evt.Timestamp < state.DownloadStartedAt.Value)
state.DownloadStartedAt = evt.Timestamp;
if (summary.Status == "InProgress" || summary.Status == string.Empty)
summary.Status = "InProgress";
break;
case "app_install_completed":
case "app_install_complete":
summary.Status = "Succeeded";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds);
break;
case "app_install_failed":
summary.Status = "Failed";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds);
// Agent does not send errorCode/errorMessage in Data — use the event message
summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true
? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty;
summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true
? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty;
break;
case "app_install_skipped":
// Mark as succeeded (skipped = already installed / not applicable) with 0 duration
if (summary.Status == "InProgress")
summary.Status = "Succeeded";
break;
case "download_progress":
// Agent sends "bytesDownloaded" (not "bytes_downloaded")
var bytesKey = evt.Data?.ContainsKey("bytesDownloaded") == true ? "bytesDownloaded"
: evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null;
if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes);
break;
case "do_telemetry":
if (evt.Data != null)
{
if (evt.Data.ContainsKey("doFileSize") && long.TryParse(evt.Data["doFileSize"]?.ToString(), out var doFs))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, doFs);
if (evt.Data.ContainsKey("doBytesFromPeers") && long.TryParse(evt.Data["doBytesFromPeers"]?.ToString(), out var doPeers))
summary.DoBytesFromPeers = doPeers;
if (evt.Data.ContainsKey("doBytesFromHttp") && long.TryParse(evt.Data["doBytesFromHttp"]?.ToString(), out var doHttp))
summary.DoBytesFromHttp = doHttp;
if (evt.Data.ContainsKey("doPercentPeerCaching") && int.TryParse(evt.Data["doPercentPeerCaching"]?.ToString(), out var doPct))
summary.DoPercentPeerCaching = doPct;
if (evt.Data.ContainsKey("doDownloadMode") && int.TryParse(evt.Data["doDownloadMode"]?.ToString(), out var doMode))
summary.DoDownloadMode = doMode;
if (evt.Data.ContainsKey("doDownloadDuration"))
{
var doDurStr = evt.Data["doDownloadDuration"]?.ToString() ?? string.Empty;
summary.DoDownloadDuration = doDurStr;
// Use DO's measured duration for throughput calculation (more accurate than timestamp diff)
if (TimeSpan.TryParse(doDurStr, out var doDurTs) && doDurTs.TotalSeconds >= 1)
summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, (int)doDurTs.TotalSeconds);
}
if (evt.Data.ContainsKey("doBytesFromLanPeers") && long.TryParse(evt.Data["doBytesFromLanPeers"]?.ToString(), out var doLan))
summary.DoBytesFromLanPeers = doLan;
if (evt.Data.ContainsKey("doBytesFromGroupPeers") && long.TryParse(evt.Data["doBytesFromGroupPeers"]?.ToString(), out var doGroup))
summary.DoBytesFromGroupPeers = doGroup;
if (evt.Data.ContainsKey("doBytesFromInternetPeers") && long.TryParse(evt.Data["doBytesFromInternetPeers"]?.ToString(), out var doInet))
summary.DoBytesFromInternetPeers = doInet;
}
break;
}
RecalculateAppDurations(state);
}
private static void RecalculateAppDurations(AppInstallAggregationState state)
{
var summary = state.Summary;
// Effective start for full app duration: earliest known install/download start.
var effectiveStart = summary.StartedAt;
if (state.DownloadStartedAt.HasValue &&
(effectiveStart == DateTime.MinValue || state.DownloadStartedAt.Value < effectiveStart))
{
effectiveStart = state.DownloadStartedAt.Value;
}
if (state.InstallStartedAt.HasValue &&
(effectiveStart == DateTime.MinValue || state.InstallStartedAt.Value < effectiveStart))
{
effectiveStart = state.InstallStartedAt.Value;
}
if (effectiveStart != DateTime.MinValue)
{
summary.StartedAt = effectiveStart;
}
// Download duration: from first download start to first install start.
if (state.DownloadStartedAt.HasValue && state.InstallStartedAt.HasValue &&
state.InstallStartedAt.Value >= state.DownloadStartedAt.Value)
{
summary.DownloadDurationSeconds = (int)(state.InstallStartedAt.Value - state.DownloadStartedAt.Value).TotalSeconds;
}
// Full duration: from effective start to completion/failure.
if (summary.CompletedAt.HasValue && summary.StartedAt != DateTime.MinValue &&
summary.CompletedAt.Value >= summary.StartedAt)
{
summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds;
}
}
private async Task<IngestEventsOutput> CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message)
{
var response = req.CreateResponse(statusCode);
var errorResponse = new IngestEventsResponse
{
Success = false,
EventsReceived = 0,
EventsProcessed = 0,
Message = message,
ProcessedAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(errorResponse);
return new IngestEventsOutput { HttpResponse = response, SignalRMessages = Array.Empty<SignalRMessageAction>() };
}
}
public class IngestEventsOutput
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Ingest
{
/// <summary>
/// Emergency channel endpoint: receives critical agent-side error reports when the
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
public class GetAppMetricsFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
public class GetGalacticAppMetricsFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
public class GetGalacticGeographicMetricsFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Function for retrieving platform agent metrics (Galactic Admin only).
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
public class GetGeographicMetricsFunction
{
@@ -4,7 +4,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Public endpoint - no authentication required.
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Function for retrieving platform usage metrics (Galactic Admin only)
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Function for retrieving tenant-specific usage metrics (Tenant Admin)
@@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Returns a short-lived SAS download URL for a session report blob stored in central blob storage.
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Reports
{
public class GetSessionReportsFunction
{
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Reports
{
public class SubmitSessionReportFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Allows Galactic Admins to add or update an admin note on a submitted session report.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// CRUD API for managing analyze rules (portal-facing, JWT auth)
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// CRUD API for managing gather rules (portal-facing, JWT auth)
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// Returns analysis results (rule evaluations) for a session.
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// API for managing IME log patterns (portal-facing, JWT auth).
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
namespace AutopilotMonitor.Functions.Functions.Rules;
/// <summary>
/// CRUD endpoints for managing the Private Preview tenant whitelist.
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class DeleteSessionFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class GetAllSessionsFunction
{
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class GetSessionEventsFunction
{
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class GetSessionFunction
{
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class GetSessionsFunction
{
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class MarkSessionFailedFunction
{
@@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class RegisterSessionFunction
{
@@ -1,5 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -113,10 +116,10 @@ builder.Services.AddSingleton<SessionReportService>();
builder.Services.AddSingleton<BootstrapSessionService>();
// Register agent Function classes so bootstrap wrappers can inject them for code reuse
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.IngestEventsFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.RegisterSessionFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.GetAgentConfigFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.ReportAgentErrorFunction>();
builder.Services.AddSingleton<IngestEventsFunction>();
builder.Services.AddSingleton<RegisterSessionFunction>();
builder.Services.AddSingleton<GetAgentConfigFunction>();
builder.Services.AddSingleton<ReportAgentErrorFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
@@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Partial: Metrics aggregation, data cleanup, and platform stats recomputation.
/// </summary>
public partial class MaintenanceService
{
private async Task AggregateMetricsWithCatchUpAsync()
{
const int maxCatchUpDays = 7;
var today = DateTime.UtcNow.Date;
var aggregatedCount = 0;
for (int daysBack = maxCatchUpDays; daysBack >= 1; daysBack--)
{
var date = today.AddDays(-daysBack);
var dateStr = date.ToString("yyyy-MM-dd");
try
{
if (await _storageService.HasUsageMetricsSnapshotAsync(dateStr))
continue;
_logger.LogInformation($"Catch-up: Aggregating metrics for missed date {dateStr}");
await AggregateMetricsForDateAsync(date);
aggregatedCount++;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to aggregate metrics for {dateStr} during catch-up");
}
}
if (aggregatedCount > 0)
_logger.LogInformation($"Catch-up completed: aggregated {aggregatedCount} missed day(s)");
else
_logger.LogInformation("No missed days to catch up on");
}
/// <summary>
/// Aggregates metrics for a specific date and saves them as historical snapshots
/// </summary>
private async Task AggregateMetricsForDateAsync(DateTime targetDate)
{
_logger.LogInformation($"Aggregating metrics for {targetDate:yyyy-MM-dd}...");
var aggregateStart = Stopwatch.StartNew();
try
{
var targetDateStr = targetDate.ToString("yyyy-MM-dd");
var targetDateSessions = await _storageService.GetSessionsByDateRangeAsync(targetDate, targetDate.AddDays(1));
if (targetDateSessions.Count == 0)
{
_logger.LogInformation($"No sessions found for {targetDateStr}");
return;
}
var globalMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, "global", targetDateSessions);
await _storageService.SaveUsageMetricsSnapshotAsync(globalMetrics);
var tenantGroups = targetDateSessions.GroupBy(s => s.TenantId);
foreach (var tenantGroup in tenantGroups)
{
var tenantMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, tenantGroup.Key, tenantGroup.ToList());
await _storageService.SaveUsageMetricsSnapshotAsync(tenantMetrics);
}
aggregateStart.Stop();
_logger.LogInformation($"Aggregated metrics for {targetDateSessions.Count} sessions from {targetDateStr} in {aggregateStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to aggregate metrics for {targetDate:yyyy-MM-dd}");
throw;
}
}
/// <summary>
/// Computes historical metrics for a specific date and tenant
/// </summary>
private async Task<UsageMetricsSnapshot> ComputeUsageMetricsSnapshotAsync(string date, string tenantId, List<SessionSummary> sessions)
{
var computeStart = Stopwatch.StartNew();
var completed = sessions.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed).ToList();
var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded);
var successRate = completed.Count > 0 ? Math.Round((succeeded / (double)completed.Count) * 100, 1) : 0;
var completedWithDuration = sessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
double avgDuration = 0, medianDuration = 0, p95Duration = 0, p99Duration = 0;
if (completedWithDuration.Any())
{
var durations = completedWithDuration.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList();
avgDuration = Math.Round(durations.Average(), 1);
medianDuration = CalculatePercentile(durations, 50);
p95Duration = CalculatePercentile(durations, 95);
p99Duration = CalculatePercentile(durations, 99);
}
var manufacturers = sessions
.GroupBy(s => s.Manufacturer)
.Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
var models = sessions
.GroupBy(s => s.Model)
.Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
var targetDate = DateTime.ParseExact(date, "yyyy-MM-dd", null);
var (uniqueUsers, loginCount) = await _storageService.GetUserActivityForDateAsync(
tenantId == "global" ? null : tenantId, targetDate);
computeStart.Stop();
return new UsageMetricsSnapshot
{
Date = date,
TenantId = tenantId,
ComputedAt = DateTime.UtcNow,
ComputeDurationMs = (int)computeStart.ElapsedMilliseconds,
SessionsTotal = sessions.Count,
SessionsSucceeded = succeeded,
SessionsFailed = sessions.Count(s => s.Status == SessionStatus.Failed),
SessionsInProgress = sessions.Count(s => s.Status == SessionStatus.InProgress),
SessionsSuccessRate = successRate,
AvgDurationMinutes = avgDuration,
MedianDurationMinutes = medianDuration,
P95DurationMinutes = p95Duration,
P99DurationMinutes = p99Duration,
UniqueTenants = tenantId == "global" ? sessions.Select(s => s.TenantId).Distinct().Count() : 0,
UserDrivenSessions = sessions.Count(s => s.IsUserDriven),
WhiteGloveSessions = sessions.Count(s => s.IsPreProvisioned),
UniqueUsers = uniqueUsers,
LoginCount = loginCount,
TopManufacturers = JsonConvert.SerializeObject(manufacturers),
TopModels = JsonConvert.SerializeObject(models)
};
}
/// <summary>
/// Cleans up old sessions and events based on tenant retention policies
/// </summary>
private async Task CleanupOldDataAsync()
{
_logger.LogInformation("Starting data retention cleanup...");
var cleanupStart = Stopwatch.StartNew();
try
{
var tenantIds = await _storageService.GetAllTenantIdsAsync();
int totalSessionsDeleted = 0;
int totalEventsDeleted = 0;
foreach (var tenantId in tenantIds)
{
try
{
var config = await _tenantConfigService.GetConfigurationAsync(tenantId);
var retentionDays = config?.DataRetentionDays ?? 90;
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var oldSessions = await _storageService.GetSessionsOlderThanAsync(tenantId, cutoffDate);
if (oldSessions.Count == 0)
{
_logger.LogInformation($"Tenant {tenantId}: No sessions older than {retentionDays} days");
continue;
}
int sessionCount = 0;
int eventCount = 0;
int ruleResultCount = 0;
int appSummaryCount = 0;
foreach (var session in oldSessions)
{
var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId);
eventCount += deletedEvents;
var deletedRuleResults = await _storageService.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId);
ruleResultCount += deletedRuleResults;
var deletedAppSummaries = await _storageService.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId);
appSummaryCount += deletedAppSummaries;
await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId);
sessionCount++;
}
totalSessionsDeleted += sessionCount;
totalEventsDeleted += eventCount;
await _storageService.LogAuditEntryAsync(
tenantId,
"DataRetentionCleanup",
"Session",
$"{sessionCount} sessions",
"System.DailyMaintenance",
new Dictionary<string, string>
{
{ "SessionsDeleted", sessionCount.ToString() },
{ "EventsDeleted", eventCount.ToString() },
{ "RuleResultsDeleted", ruleResultCount.ToString() },
{ "AppSummariesDeleted", appSummaryCount.ToString() },
{ "RetentionDays", retentionDays.ToString() },
{ "CutoffDate", cutoffDate.ToString("yyyy-MM-dd") }
});
_logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions, {eventCount} events, {ruleResultCount} rule results, {appSummaryCount} app summaries (retention: {retentionDays} days)");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to cleanup data for tenant {tenantId}");
}
}
cleanupStart.Stop();
_logger.LogInformation($"Data retention cleanup completed: {totalSessionsDeleted} sessions and {totalEventsDeleted} events deleted in {cleanupStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cleanup old data");
}
}
/// <summary>
/// Recomputes platform-wide stats from all tables.
/// Used on the public landing page (no auth required).
/// </summary>
private async Task RecomputePlatformStatsAsync()
{
_logger.LogInformation("Recomputing platform stats...");
var sw = Stopwatch.StartNew();
try
{
var tenantIds = await _storageService.GetAllTenantIdsAsync();
var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync();
long totalEnrollments = 0;
long successfulEnrollments = 0;
long totalEvents = 0;
long totalUsers = 0;
var uniqueModels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tid in tenantIds)
{
var sessions = await _storageService.GetSessionsAsync(tid, maxResults: 10000);
totalEnrollments += sessions.Count;
successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded);
foreach (var s in sessions)
{
var modelKey = $"{s.Manufacturer} {s.Model}".Trim();
if (!string.IsNullOrEmpty(modelKey))
uniqueModels.Add(modelKey);
totalEvents += s.EventCount;
}
var userMetrics = await _storageService.GetUserActivityMetricsAsync(tid);
totalUsers += userMetrics.TotalUniqueUsers;
}
var existingStats = await _storageService.GetPlatformStatsAsync();
var stats = new PlatformStats
{
TotalEnrollments = totalEnrollments,
TotalUsers = totalUsers,
TotalTenants = tenantIds.Count,
TotalSignedUpTenants = allConfigs.Count,
UniqueDeviceModels = uniqueModels.Count,
TotalEventsProcessed = totalEvents,
SuccessfulEnrollments = successfulEnrollments,
IssuesDetected = existingStats?.IssuesDetected ?? 0,
LastFullCompute = DateTime.UtcNow,
LastUpdated = DateTime.UtcNow
};
await _storageService.SavePlatformStatsAsync(stats);
await TryPublishPlatformStatsJsonAsync(stats);
sw.Stop();
_logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " +
$"{totalEnrollments} enrollments, {totalUsers} users, {tenantIds.Count} tenants, {uniqueModels.Count} models");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to recompute platform stats");
}
}
/// <summary>
/// Publishes versioned platform stats JSON + alias manifest to Blob Storage.
/// This must never fail maintenance execution.
/// </summary>
private async Task TryPublishPlatformStatsJsonAsync(PlatformStats stats)
{
try
{
var adminConfig = await _adminConfigurationService.GetConfigurationAsync();
var containerSasUrl = adminConfig.PlatformStatsBlobSasUrl?.Trim();
if (string.IsNullOrWhiteSpace(containerSasUrl))
{
_logger.LogInformation("Skipping platform stats JSON publish: PlatformStatsBlobSasUrl is not configured.");
return;
}
var containerClient = new BlobContainerClient(new Uri(containerSasUrl));
var generatedAtUtc = DateTime.UtcNow;
var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json";
var versionedPayload = new
{
totalEnrollments = stats.TotalEnrollments,
totalUsers = stats.TotalUsers,
totalTenants = stats.TotalTenants,
totalSignedUpTenants = stats.TotalSignedUpTenants,
uniqueDeviceModels = stats.UniqueDeviceModels,
totalEventsProcessed = stats.TotalEventsProcessed,
successfulEnrollments = stats.SuccessfulEnrollments,
issuesDetected = stats.IssuesDetected,
lastFullCompute = stats.LastFullCompute,
lastUpdated = stats.LastUpdated
};
var aliasPayload = new
{
latest = versionedFileName,
generatedAtUtc = generatedAtUtc.ToString("o")
};
await UploadJsonBlobAsync(containerClient, versionedFileName, versionedPayload, PlatformStatsVersionedCacheControl);
await UploadJsonBlobAsync(containerClient, PlatformStatsAliasFileName, aliasPayload, PlatformStatsAliasCacheControl);
_logger.LogInformation(
"Published platform stats JSON blobs: versioned={VersionedFile} and alias={AliasFile}",
versionedFileName,
PlatformStatsAliasFileName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish platform stats JSON to Blob Storage. Maintenance continues.");
}
}
private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl)
{
var blobClient = containerClient.GetBlobClient(blobName);
var json = JsonConvert.SerializeObject(payload);
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, overwrite: true);
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
{
ContentType = "application/json; charset=utf-8",
CacheControl = cacheControl
});
await blobClient.SetAccessTierAsync(AccessTier.Hot);
}
/// <summary>
/// Calculates percentile value from sorted list
/// </summary>
private double CalculatePercentile(List<double> sortedValues, int percentile)
{
if (sortedValues.Count == 0) return 0;
var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1;
index = Math.Max(0, Math.Min(index, sortedValues.Count - 1));
return Math.Round(sortedValues[index], 1);
}
}
}
@@ -38,7 +38,7 @@ namespace AutopilotMonitor.Functions.Services
/// 3. Deletes old sessions and events based on tenant retention policies
/// 4. Recomputes platform-wide stats for the landing page
/// </summary>
public class MaintenanceService
public partial class MaintenanceService
{
private readonly TableStorageService _storageService;
private readonly TenantConfigurationService _tenantConfigService;
@@ -318,378 +318,5 @@ namespace AutopilotMonitor.Functions.Services
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
/// </summary>
private async Task AggregateMetricsWithCatchUpAsync()
{
const int maxCatchUpDays = 7;
var today = DateTime.UtcNow.Date;
var aggregatedCount = 0;
for (int daysBack = maxCatchUpDays; daysBack >= 1; daysBack--)
{
var date = today.AddDays(-daysBack);
var dateStr = date.ToString("yyyy-MM-dd");
try
{
if (await _storageService.HasUsageMetricsSnapshotAsync(dateStr))
continue;
_logger.LogInformation($"Catch-up: Aggregating metrics for missed date {dateStr}");
await AggregateMetricsForDateAsync(date);
aggregatedCount++;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to aggregate metrics for {dateStr} during catch-up");
}
}
if (aggregatedCount > 0)
_logger.LogInformation($"Catch-up completed: aggregated {aggregatedCount} missed day(s)");
else
_logger.LogInformation("No missed days to catch up on");
}
/// <summary>
/// Aggregates metrics for a specific date and saves them as historical snapshots
/// </summary>
private async Task AggregateMetricsForDateAsync(DateTime targetDate)
{
_logger.LogInformation($"Aggregating metrics for {targetDate:yyyy-MM-dd}...");
var aggregateStart = Stopwatch.StartNew();
try
{
var targetDateStr = targetDate.ToString("yyyy-MM-dd");
var targetDateSessions = await _storageService.GetSessionsByDateRangeAsync(targetDate, targetDate.AddDays(1));
if (targetDateSessions.Count == 0)
{
_logger.LogInformation($"No sessions found for {targetDateStr}");
return;
}
var globalMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, "global", targetDateSessions);
await _storageService.SaveUsageMetricsSnapshotAsync(globalMetrics);
var tenantGroups = targetDateSessions.GroupBy(s => s.TenantId);
foreach (var tenantGroup in tenantGroups)
{
var tenantMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, tenantGroup.Key, tenantGroup.ToList());
await _storageService.SaveUsageMetricsSnapshotAsync(tenantMetrics);
}
aggregateStart.Stop();
_logger.LogInformation($"Aggregated metrics for {targetDateSessions.Count} sessions from {targetDateStr} in {aggregateStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to aggregate metrics for {targetDate:yyyy-MM-dd}");
throw;
}
}
/// <summary>
/// Computes historical metrics for a specific date and tenant
/// </summary>
private async Task<UsageMetricsSnapshot> ComputeUsageMetricsSnapshotAsync(string date, string tenantId, List<SessionSummary> sessions)
{
var computeStart = Stopwatch.StartNew();
var completed = sessions.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed).ToList();
var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded);
var successRate = completed.Count > 0 ? Math.Round((succeeded / (double)completed.Count) * 100, 1) : 0;
var completedWithDuration = sessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
double avgDuration = 0, medianDuration = 0, p95Duration = 0, p99Duration = 0;
if (completedWithDuration.Any())
{
var durations = completedWithDuration.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList();
avgDuration = Math.Round(durations.Average(), 1);
medianDuration = CalculatePercentile(durations, 50);
p95Duration = CalculatePercentile(durations, 95);
p99Duration = CalculatePercentile(durations, 99);
}
var manufacturers = sessions
.GroupBy(s => s.Manufacturer)
.Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
var models = sessions
.GroupBy(s => s.Model)
.Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
var targetDate = DateTime.ParseExact(date, "yyyy-MM-dd", null);
var (uniqueUsers, loginCount) = await _storageService.GetUserActivityForDateAsync(
tenantId == "global" ? null : tenantId, targetDate);
computeStart.Stop();
return new UsageMetricsSnapshot
{
Date = date,
TenantId = tenantId,
ComputedAt = DateTime.UtcNow,
ComputeDurationMs = (int)computeStart.ElapsedMilliseconds,
SessionsTotal = sessions.Count,
SessionsSucceeded = succeeded,
SessionsFailed = sessions.Count(s => s.Status == SessionStatus.Failed),
SessionsInProgress = sessions.Count(s => s.Status == SessionStatus.InProgress),
SessionsSuccessRate = successRate,
AvgDurationMinutes = avgDuration,
MedianDurationMinutes = medianDuration,
P95DurationMinutes = p95Duration,
P99DurationMinutes = p99Duration,
UniqueTenants = tenantId == "global" ? sessions.Select(s => s.TenantId).Distinct().Count() : 0,
UserDrivenSessions = sessions.Count(s => s.IsUserDriven),
WhiteGloveSessions = sessions.Count(s => s.IsPreProvisioned),
UniqueUsers = uniqueUsers,
LoginCount = loginCount,
TopManufacturers = JsonConvert.SerializeObject(manufacturers),
TopModels = JsonConvert.SerializeObject(models)
};
}
/// <summary>
/// Cleans up old sessions and events based on tenant retention policies
/// </summary>
private async Task CleanupOldDataAsync()
{
_logger.LogInformation("Starting data retention cleanup...");
var cleanupStart = Stopwatch.StartNew();
try
{
var tenantIds = await _storageService.GetAllTenantIdsAsync();
int totalSessionsDeleted = 0;
int totalEventsDeleted = 0;
foreach (var tenantId in tenantIds)
{
try
{
var config = await _tenantConfigService.GetConfigurationAsync(tenantId);
var retentionDays = config?.DataRetentionDays ?? 90;
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var oldSessions = await _storageService.GetSessionsOlderThanAsync(tenantId, cutoffDate);
if (oldSessions.Count == 0)
{
_logger.LogInformation($"Tenant {tenantId}: No sessions older than {retentionDays} days");
continue;
}
int sessionCount = 0;
int eventCount = 0;
int ruleResultCount = 0;
int appSummaryCount = 0;
foreach (var session in oldSessions)
{
var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId);
eventCount += deletedEvents;
var deletedRuleResults = await _storageService.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId);
ruleResultCount += deletedRuleResults;
var deletedAppSummaries = await _storageService.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId);
appSummaryCount += deletedAppSummaries;
await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId);
sessionCount++;
}
totalSessionsDeleted += sessionCount;
totalEventsDeleted += eventCount;
await _storageService.LogAuditEntryAsync(
tenantId,
"DataRetentionCleanup",
"Session",
$"{sessionCount} sessions",
"System.DailyMaintenance",
new Dictionary<string, string>
{
{ "SessionsDeleted", sessionCount.ToString() },
{ "EventsDeleted", eventCount.ToString() },
{ "RuleResultsDeleted", ruleResultCount.ToString() },
{ "AppSummariesDeleted", appSummaryCount.ToString() },
{ "RetentionDays", retentionDays.ToString() },
{ "CutoffDate", cutoffDate.ToString("yyyy-MM-dd") }
});
_logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions, {eventCount} events, {ruleResultCount} rule results, {appSummaryCount} app summaries (retention: {retentionDays} days)");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to cleanup data for tenant {tenantId}");
}
}
cleanupStart.Stop();
_logger.LogInformation($"Data retention cleanup completed: {totalSessionsDeleted} sessions and {totalEventsDeleted} events deleted in {cleanupStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cleanup old data");
}
}
/// <summary>
/// Recomputes platform-wide stats from all tables.
/// Used on the public landing page (no auth required).
/// </summary>
private async Task RecomputePlatformStatsAsync()
{
_logger.LogInformation("Recomputing platform stats...");
var sw = Stopwatch.StartNew();
try
{
var tenantIds = await _storageService.GetAllTenantIdsAsync();
var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync();
long totalEnrollments = 0;
long successfulEnrollments = 0;
long totalEvents = 0;
long totalUsers = 0;
var uniqueModels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tid in tenantIds)
{
var sessions = await _storageService.GetSessionsAsync(tid, maxResults: 10000);
totalEnrollments += sessions.Count;
successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded);
foreach (var s in sessions)
{
var modelKey = $"{s.Manufacturer} {s.Model}".Trim();
if (!string.IsNullOrEmpty(modelKey))
uniqueModels.Add(modelKey);
totalEvents += s.EventCount;
}
var userMetrics = await _storageService.GetUserActivityMetricsAsync(tid);
totalUsers += userMetrics.TotalUniqueUsers;
}
var existingStats = await _storageService.GetPlatformStatsAsync();
var stats = new PlatformStats
{
TotalEnrollments = totalEnrollments,
TotalUsers = totalUsers,
TotalTenants = tenantIds.Count,
TotalSignedUpTenants = allConfigs.Count,
UniqueDeviceModels = uniqueModels.Count,
TotalEventsProcessed = totalEvents,
SuccessfulEnrollments = successfulEnrollments,
IssuesDetected = existingStats?.IssuesDetected ?? 0,
LastFullCompute = DateTime.UtcNow,
LastUpdated = DateTime.UtcNow
};
await _storageService.SavePlatformStatsAsync(stats);
await TryPublishPlatformStatsJsonAsync(stats);
sw.Stop();
_logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " +
$"{totalEnrollments} enrollments, {totalUsers} users, {tenantIds.Count} tenants, {uniqueModels.Count} models");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to recompute platform stats");
}
}
/// <summary>
/// Publishes versioned platform stats JSON + alias manifest to Blob Storage.
/// This must never fail maintenance execution.
/// </summary>
private async Task TryPublishPlatformStatsJsonAsync(PlatformStats stats)
{
try
{
var adminConfig = await _adminConfigurationService.GetConfigurationAsync();
var containerSasUrl = adminConfig.PlatformStatsBlobSasUrl?.Trim();
if (string.IsNullOrWhiteSpace(containerSasUrl))
{
_logger.LogInformation("Skipping platform stats JSON publish: PlatformStatsBlobSasUrl is not configured.");
return;
}
var containerClient = new BlobContainerClient(new Uri(containerSasUrl));
var generatedAtUtc = DateTime.UtcNow;
var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json";
var versionedPayload = new
{
totalEnrollments = stats.TotalEnrollments,
totalUsers = stats.TotalUsers,
totalTenants = stats.TotalTenants,
totalSignedUpTenants = stats.TotalSignedUpTenants,
uniqueDeviceModels = stats.UniqueDeviceModels,
totalEventsProcessed = stats.TotalEventsProcessed,
successfulEnrollments = stats.SuccessfulEnrollments,
issuesDetected = stats.IssuesDetected,
lastFullCompute = stats.LastFullCompute,
lastUpdated = stats.LastUpdated
};
var aliasPayload = new
{
latest = versionedFileName,
generatedAtUtc = generatedAtUtc.ToString("o")
};
await UploadJsonBlobAsync(containerClient, versionedFileName, versionedPayload, PlatformStatsVersionedCacheControl);
await UploadJsonBlobAsync(containerClient, PlatformStatsAliasFileName, aliasPayload, PlatformStatsAliasCacheControl);
_logger.LogInformation(
"Published platform stats JSON blobs: versioned={VersionedFile} and alias={AliasFile}",
versionedFileName,
PlatformStatsAliasFileName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish platform stats JSON to Blob Storage. Maintenance continues.");
}
}
private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl)
{
var blobClient = containerClient.GetBlobClient(blobName);
var json = JsonConvert.SerializeObject(payload);
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, overwrite: true);
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
{
ContentType = "application/json; charset=utf-8",
CacheControl = cacheControl
});
await blobClient.SetAccessTierAsync(AccessTier.Hot);
}
/// <summary>
/// Calculates percentile value from sorted list
/// </summary>
private double CalculatePercentile(List<double> sortedValues, int percentile)
{
if (sortedValues.Count == 0) return 0;
var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1;
index = Math.Max(0, Math.Min(index, sortedValues.Count - 1));
return Math.Round(sortedValues[index], 1);
}
}
}
@@ -0,0 +1,534 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Partial: Individual condition evaluators (event type, event data, event count,
/// phase duration, app install duration, event correlation, confidence factors).
/// </summary>
public partial class RuleEngine
{
private (bool matched, object evidence) EvaluateCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
switch (condition.Source)
{
case "event_type":
return EvaluateEventTypeCondition(condition, events);
case "event_data":
return EvaluateEventDataCondition(condition, events);
case "event_count":
return EvaluateEventCountCondition(condition, events);
case "phase_duration":
return EvaluatePhaseDurationCondition(condition, events);
case "app_install_duration":
return EvaluateAppInstallDurationCondition(condition, events);
case "event_correlation":
return EvaluateEventCorrelationCondition(condition, events);
default:
return (false, "unknown source");
}
}
private (bool matched, object evidence) EvaluateEventTypeCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
if (!matchingEvents.Any())
return (false, "no matching events");
// If DataField is specified, check data within matching events
if (!string.IsNullOrEmpty(condition.DataField))
{
foreach (var evt in matchingEvents)
{
var fieldValue = GetDataFieldValue(evt, condition.DataField);
if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value))
{
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["timestamp"] = evt.Timestamp,
["eventType"] = evt.EventType,
["field"] = condition.DataField,
["value"] = fieldValue
});
}
}
return (false, "data field not matched");
}
// Just check if event type exists — return first matching event for reference
var first = matchingEvents[0];
return (true, new Dictionary<string, object>
{
["eventId"] = first.EventId,
["sequence"] = first.Sequence,
["timestamp"] = first.Timestamp,
["eventType"] = condition.EventType,
["count"] = matchingEvents.Count
});
}
private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
foreach (var evt in matchingEvents)
{
var fieldValue = GetDataFieldValue(evt, condition.DataField);
if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value))
{
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["timestamp"] = evt.Timestamp,
["field"] = condition.DataField,
["value"] = fieldValue
});
}
}
return (false, "no matching data");
}
private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
// count_per_group_gte: group by DataField value (e.g. appId), fire if any group >= threshold
if (condition.Operator == "count_per_group_gte" && !string.IsNullOrEmpty(condition.DataField) && int.TryParse(condition.Value, out var groupThreshold))
{
var groups = matchingEvents
.Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.DataField) })
.Where(x => !string.IsNullOrEmpty(x.Key))
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= groupThreshold)
.ToList();
if (groups.Any())
{
var worst = groups.OrderByDescending(g => g.Count()).First();
var firstEvent = worst.First().Event;
string groupKey = worst.Key ?? string.Empty;
string appName = GetDataFieldValue(firstEvent, "appName") ?? groupKey;
return (true, new Dictionary<string, object>
{
["eventId"] = firstEvent.EventId ?? string.Empty,
["sequence"] = firstEvent.Sequence,
["timestamp"] = firstEvent.Timestamp,
["groupKey"] = groupKey,
["appName"] = appName,
["count"] = worst.Count(),
["threshold"] = groupThreshold
});
}
var distinctApps = matchingEvents
.Select(e => GetDataFieldValue(e, condition.DataField))
.Where(k => k != null)
.Cast<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
return (false, new Dictionary<string, object> { ["totalEvents"] = matchingEvents.Count, ["distinctApps"] = distinctApps });
}
// count_gte: global count across all matching events
var count = matchingEvents.Count;
if (condition.Operator == "count_gte" && int.TryParse(condition.Value, out var threshold))
{
if (count >= threshold)
{
var first = matchingEvents[0];
return (true, new Dictionary<string, object>
{
["eventId"] = first.EventId,
["sequence"] = first.Sequence,
["timestamp"] = first.Timestamp,
["count"] = count,
["threshold"] = threshold
});
}
}
return (false, new Dictionary<string, object> { ["count"] = count });
}
private static (bool matched, object evidence) EvaluatePhaseDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
// Find phase change events to calculate phase duration
var phaseEvents = events
.Where(e => e.EventType == "esp_phase_changed")
.OrderBy(e => e.Timestamp)
.ToList();
if (!phaseEvents.Any())
return (false, "no phase events");
// condition.DataField = field to look up (e.g. "espPhase"), condition.Value = target phase name (e.g. "DeviceSetup")
var targetPhase = condition.Value;
var lookupField = string.IsNullOrEmpty(condition.DataField) ? "espPhase" : condition.DataField;
for (int i = 0; i < phaseEvents.Count; i++)
{
var evt = phaseEvents[i];
var currentPhase = evt.Data?.ContainsKey(lookupField) == true
? evt.Data[lookupField]?.ToString()
: null;
if (!string.Equals(currentPhase, targetPhase, StringComparison.OrdinalIgnoreCase))
continue;
// Calculate how long this phase lasted
DateTime phaseEnd;
string? phaseEndEventId = null;
if (i + 1 < phaseEvents.Count)
{
phaseEnd = phaseEvents[i + 1].Timestamp;
phaseEndEventId = phaseEvents[i + 1].EventId;
}
else
{
phaseEnd = DateTime.UtcNow; // Phase is still active
}
var durationSeconds = (phaseEnd - evt.Timestamp).TotalSeconds;
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["phaseStartTimestamp"] = evt.Timestamp,
["phaseEndEventId"] = phaseEndEventId ?? "(still active)",
["phase"] = targetPhase,
["durationSeconds"] = durationSeconds,
["durationFormatted"] = FormatDuration(durationSeconds)
});
}
return (false, "phase not found");
}
private static string FormatDuration(double totalSeconds)
{
var ts = TimeSpan.FromSeconds(totalSeconds);
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{ts.Minutes}m {ts.Seconds}s";
return $"{ts.Seconds}s";
}
private bool EvaluateConfidenceFactor(ConfidenceFactor factor, List<EnrollmentEvent> events, Dictionary<string, object> matchedConditions)
{
if (factor.Condition.StartsWith("phase_duration >"))
{
if (int.TryParse(factor.Condition.Replace("phase_duration >", "").Trim(), out var threshold))
{
foreach (var mc in matchedConditions.Values)
{
if (mc is Dictionary<string, object> dict && dict.TryGetValue("durationSeconds", out var rawDuration))
{
var duration = Convert.ToDouble(rawDuration);
return duration > threshold;
}
}
}
}
else if (factor.Condition == "exists")
{
return matchedConditions.ContainsKey(factor.Signal);
}
else if (factor.Condition.StartsWith("count >="))
{
if (int.TryParse(factor.Condition.Replace("count >=", "").Trim(), out var threshold))
{
var count = events.Count(e => e.EventType == factor.Signal);
return count >= threshold;
}
}
return false;
}
// ===== HELPERS =====
private bool MatchesEventType(EnrollmentEvent evt, string eventType)
{
if (string.IsNullOrEmpty(eventType))
return false;
return string.Equals(evt.EventType, eventType, StringComparison.OrdinalIgnoreCase);
}
private string? GetDataFieldValue(EnrollmentEvent evt, string dataField)
{
if (evt.Data == null || string.IsNullOrEmpty(dataField))
return null;
// Support checking message field directly
if (dataField.Equals("message", StringComparison.OrdinalIgnoreCase))
return evt.Message;
// Check in Data dictionary
if (evt.Data.TryGetValue(dataField, out var value))
return value?.ToString();
// Try case-insensitive lookup
var key = evt.Data.Keys.FirstOrDefault(k => k.Equals(dataField, StringComparison.OrdinalIgnoreCase));
if (key != null)
return evt.Data[key]?.ToString();
return null;
}
private bool MatchesOperator(string fieldValue, string op, string compareValue)
{
switch (op?.ToLower())
{
case "equals":
return string.Equals(fieldValue, compareValue, StringComparison.OrdinalIgnoreCase);
case "contains":
return fieldValue.IndexOf(compareValue, StringComparison.OrdinalIgnoreCase) >= 0;
case "regex":
try
{
return Regex.IsMatch(fieldValue, compareValue, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
}
catch
{
return false;
}
case "gt":
return double.TryParse(fieldValue, out var gt1) && double.TryParse(compareValue, out var gt2) && gt1 > gt2;
case "lt":
return double.TryParse(fieldValue, out var lt1) && double.TryParse(compareValue, out var lt2) && lt1 < lt2;
case "gte":
return double.TryParse(fieldValue, out var gte1) && double.TryParse(compareValue, out var gte2) && gte1 >= gte2;
case "lte":
return double.TryParse(fieldValue, out var lte1) && double.TryParse(compareValue, out var lte2) && lte1 <= lte2;
case "exists":
return !string.IsNullOrEmpty(fieldValue);
default:
return false;
}
}
private (bool matched, object evidence) EvaluateAppInstallDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
var completionEventTypes = string.IsNullOrWhiteSpace(condition.EventType)
? new HashSet<string>(new[] { "app_install_completed", "app_install_failed" }, StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(new[] { condition.EventType }, StringComparer.OrdinalIgnoreCase);
var completionEvents = sortedEvents
.Where(e => completionEventTypes.Contains(e.EventType ?? string.Empty))
.ToList();
foreach (var completionEvent in completionEvents)
{
var appId = GetDataFieldValue(completionEvent, "appId");
var appName = GetDataFieldValue(completionEvent, "appName");
var appKey = !string.IsNullOrWhiteSpace(appId) ? appId : appName;
if (string.IsNullOrWhiteSpace(appKey))
continue;
var startEvent = sortedEvents.LastOrDefault(e =>
(string.Equals(e.EventType, "app_install_started", StringComparison.OrdinalIgnoreCase) ||
string.Equals(e.EventType, "app_install_start", StringComparison.OrdinalIgnoreCase)) &&
e.Timestamp <= completionEvent.Timestamp &&
string.Equals(GetDataFieldValue(e, "appId") ?? GetDataFieldValue(e, "appName"), appKey, StringComparison.OrdinalIgnoreCase));
if (startEvent == null)
continue;
var durationSeconds = Math.Max(0, (completionEvent.Timestamp - startEvent.Timestamp).TotalSeconds);
if (!MatchesOperator(durationSeconds.ToString(), condition.Operator, condition.Value))
continue;
return (true, new Dictionary<string, object>
{
["eventId"] = completionEvent.EventId,
["sequence"] = completionEvent.Sequence,
["startEventId"] = startEvent.EventId,
["startTimestamp"] = startEvent.Timestamp,
["endTimestamp"] = completionEvent.Timestamp,
["eventType"] = completionEvent.EventType,
["appId"] = appId ?? string.Empty,
["appName"] = appName ?? appKey,
["durationSeconds"] = durationSeconds,
["durationFormatted"] = FormatDuration(durationSeconds)
});
}
return (false, "no app install duration matched");
}
/// <summary>
/// Generic event correlation: finds pairs of events (A before B) sharing
/// the same value in a join field, with optional filters on each event
/// and optional time window constraint.
///
/// Condition fields used:
/// EventType = Event A type (required)
/// CorrelateEventType = Event B type (required)
/// JoinField = field that must match in both events (required, e.g. "appId")
/// TimeWindowSeconds = max seconds between A and B (optional, 0/null = unlimited)
/// DataField/Operator/Value = optional filter on Event B
/// EventAFilterField/Operator/Value = optional filter on Event A
/// </summary>
private (bool matched, object evidence) EvaluateEventCorrelationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
if (string.IsNullOrEmpty(condition.EventType) ||
string.IsNullOrEmpty(condition.CorrelateEventType) ||
string.IsNullOrEmpty(condition.JoinField))
{
_logger.LogWarning($"event_correlation condition '{condition.Signal}' missing required fields (EventType, CorrelateEventType, JoinField)");
return (false, "event_correlation requires EventType, CorrelateEventType, and JoinField");
}
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
// Collect Event A candidates with optional filter
var eventAList = sortedEvents
.Where(e => MatchesEventType(e, condition.EventType))
.Where(e =>
{
if (string.IsNullOrEmpty(condition.EventAFilterField))
return true;
var val = GetDataFieldValue(e, condition.EventAFilterField);
return val != null && MatchesOperator(val, condition.EventAFilterOperator ?? "exists", condition.EventAFilterValue ?? "");
})
.ToList();
// Collect Event B candidates with optional DataField/Operator/Value filter
var eventBList = sortedEvents
.Where(e => MatchesEventType(e, condition.CorrelateEventType))
.Where(e =>
{
if (string.IsNullOrEmpty(condition.DataField))
return true;
var val = GetDataFieldValue(e, condition.DataField);
return val != null && MatchesOperator(val, condition.Operator ?? "exists", condition.Value ?? "");
})
.ToList();
if (!eventAList.Any() || !eventBList.Any())
return (false, "no matching event pairs found");
// Index Event A by join field value for O(1) lookup
var eventAByJoinKey = eventAList
.Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.JoinField) })
.Where(x => !string.IsNullOrEmpty(x.Key))
.GroupBy(x => x.Key!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Select(x => x.Event).ToList(), StringComparer.OrdinalIgnoreCase);
// For each Event B, find a matching Event A (same join key, A before B, within time window)
var matchedPairs = new List<Dictionary<string, object>>();
var matchedJoinKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var eventB in eventBList)
{
var bKey = GetDataFieldValue(eventB, condition.JoinField);
if (string.IsNullOrEmpty(bKey) || !eventAByJoinKey.ContainsKey(bKey))
continue;
// Find the latest Event A that occurred before Event B
var matchingA = eventAByJoinKey[bKey]
.Where(a => a.Timestamp < eventB.Timestamp ||
(a.Timestamp == eventB.Timestamp && a.Sequence < eventB.Sequence))
.Where(a =>
{
if (condition.TimeWindowSeconds == null || condition.TimeWindowSeconds <= 0)
return true;
return (eventB.Timestamp - a.Timestamp).TotalSeconds <= condition.TimeWindowSeconds.Value;
})
.OrderByDescending(a => a.Timestamp)
.ThenByDescending(a => a.Sequence)
.FirstOrDefault();
if (matchingA == null)
continue;
// One match per join key to avoid duplicates
if (matchedJoinKeys.Contains(bKey))
continue;
matchedJoinKeys.Add(bKey);
// Build evidence for this correlated pair
var pair = new Dictionary<string, object>
{
["joinField"] = condition.JoinField,
["joinValue"] = bKey,
["eventA_eventId"] = matchingA.EventId ?? string.Empty,
["eventA_eventType"] = matchingA.EventType ?? string.Empty,
["eventA_timestamp"] = matchingA.Timestamp,
["eventA_sequence"] = matchingA.Sequence,
["eventA_message"] = matchingA.Message ?? string.Empty,
["eventB_eventId"] = eventB.EventId ?? string.Empty,
["eventB_eventType"] = eventB.EventType ?? string.Empty,
["eventB_timestamp"] = eventB.Timestamp,
["eventB_sequence"] = eventB.Sequence,
["eventB_message"] = eventB.Message ?? string.Empty,
["timeDeltaSeconds"] = (eventB.Timestamp - matchingA.Timestamp).TotalSeconds
};
// Add well-known data fields from both events
AddDataFieldsToEvidence(pair, matchingA, "eventA_", condition.JoinField);
AddDataFieldsToEvidence(pair, eventB, "eventB_", condition.JoinField);
matchedPairs.Add(pair);
}
if (!matchedPairs.Any())
return (false, "no correlated event pairs found");
// Single match: flat dictionary; multiple: first as primary + allMatches list
var primary = matchedPairs[0];
primary["totalMatches"] = matchedPairs.Count;
if (matchedPairs.Count > 1)
{
primary["allMatches"] = matchedPairs;
}
return (true, primary);
}
/// <summary>
/// Adds well-known data fields from an event to the evidence dictionary with a prefix.
/// </summary>
private void AddDataFieldsToEvidence(Dictionary<string, object> evidence, EnrollmentEvent evt, string prefix, string joinField)
{
if (evt.Data == null) return;
var knownFields = new[] { "appId", "appName", "errorPatternId", "errorDetail", "errorCode", "status" };
foreach (var field in knownFields)
{
var val = GetDataFieldValue(evt, field);
if (val != null)
evidence[$"{prefix}{field}"] = val;
}
}
}
}
@@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Services
/// Runs once at enrollment end or on-demand via "Analyze Now" button.
/// All rules (single + correlation) are evaluated in a single pass over all events.
/// </summary>
public class RuleEngine
public partial class RuleEngine
{
private readonly AnalyzeRuleService _ruleService;
private readonly TableStorageService _storageService;
@@ -152,524 +152,5 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Evaluates a single condition against the event stream
/// </summary>
private (bool matched, object evidence) EvaluateCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
switch (condition.Source)
{
case "event_type":
return EvaluateEventTypeCondition(condition, events);
case "event_data":
return EvaluateEventDataCondition(condition, events);
case "event_count":
return EvaluateEventCountCondition(condition, events);
case "phase_duration":
return EvaluatePhaseDurationCondition(condition, events);
case "app_install_duration":
return EvaluateAppInstallDurationCondition(condition, events);
case "event_correlation":
return EvaluateEventCorrelationCondition(condition, events);
default:
return (false, "unknown source");
}
}
private (bool matched, object evidence) EvaluateEventTypeCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
if (!matchingEvents.Any())
return (false, "no matching events");
// If DataField is specified, check data within matching events
if (!string.IsNullOrEmpty(condition.DataField))
{
foreach (var evt in matchingEvents)
{
var fieldValue = GetDataFieldValue(evt, condition.DataField);
if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value))
{
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["timestamp"] = evt.Timestamp,
["eventType"] = evt.EventType,
["field"] = condition.DataField,
["value"] = fieldValue
});
}
}
return (false, "data field not matched");
}
// Just check if event type exists — return first matching event for reference
var first = matchingEvents[0];
return (true, new Dictionary<string, object>
{
["eventId"] = first.EventId,
["sequence"] = first.Sequence,
["timestamp"] = first.Timestamp,
["eventType"] = condition.EventType,
["count"] = matchingEvents.Count
});
}
private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
foreach (var evt in matchingEvents)
{
var fieldValue = GetDataFieldValue(evt, condition.DataField);
if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value))
{
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["timestamp"] = evt.Timestamp,
["field"] = condition.DataField,
["value"] = fieldValue
});
}
}
return (false, "no matching data");
}
private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
// count_per_group_gte: group by DataField value (e.g. appId), fire if any group >= threshold
if (condition.Operator == "count_per_group_gte" && !string.IsNullOrEmpty(condition.DataField) && int.TryParse(condition.Value, out var groupThreshold))
{
var groups = matchingEvents
.Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.DataField) })
.Where(x => !string.IsNullOrEmpty(x.Key))
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= groupThreshold)
.ToList();
if (groups.Any())
{
var worst = groups.OrderByDescending(g => g.Count()).First();
var firstEvent = worst.First().Event;
string groupKey = worst.Key ?? string.Empty;
string appName = GetDataFieldValue(firstEvent, "appName") ?? groupKey;
return (true, new Dictionary<string, object>
{
["eventId"] = firstEvent.EventId ?? string.Empty,
["sequence"] = firstEvent.Sequence,
["timestamp"] = firstEvent.Timestamp,
["groupKey"] = groupKey,
["appName"] = appName,
["count"] = worst.Count(),
["threshold"] = groupThreshold
});
}
var distinctApps = matchingEvents
.Select(e => GetDataFieldValue(e, condition.DataField))
.Where(k => k != null)
.Cast<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
return (false, new Dictionary<string, object> { ["totalEvents"] = matchingEvents.Count, ["distinctApps"] = distinctApps });
}
// count_gte: global count across all matching events
var count = matchingEvents.Count;
if (condition.Operator == "count_gte" && int.TryParse(condition.Value, out var threshold))
{
if (count >= threshold)
{
var first = matchingEvents[0];
return (true, new Dictionary<string, object>
{
["eventId"] = first.EventId,
["sequence"] = first.Sequence,
["timestamp"] = first.Timestamp,
["count"] = count,
["threshold"] = threshold
});
}
}
return (false, new Dictionary<string, object> { ["count"] = count });
}
private static (bool matched, object evidence) EvaluatePhaseDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
// Find phase change events to calculate phase duration
var phaseEvents = events
.Where(e => e.EventType == "esp_phase_changed")
.OrderBy(e => e.Timestamp)
.ToList();
if (!phaseEvents.Any())
return (false, "no phase events");
// condition.DataField = field to look up (e.g. "espPhase"), condition.Value = target phase name (e.g. "DeviceSetup")
var targetPhase = condition.Value;
var lookupField = string.IsNullOrEmpty(condition.DataField) ? "espPhase" : condition.DataField;
for (int i = 0; i < phaseEvents.Count; i++)
{
var evt = phaseEvents[i];
var currentPhase = evt.Data?.ContainsKey(lookupField) == true
? evt.Data[lookupField]?.ToString()
: null;
if (!string.Equals(currentPhase, targetPhase, StringComparison.OrdinalIgnoreCase))
continue;
// Calculate how long this phase lasted
DateTime phaseEnd;
string? phaseEndEventId = null;
if (i + 1 < phaseEvents.Count)
{
phaseEnd = phaseEvents[i + 1].Timestamp;
phaseEndEventId = phaseEvents[i + 1].EventId;
}
else
{
phaseEnd = DateTime.UtcNow; // Phase is still active
}
var durationSeconds = (phaseEnd - evt.Timestamp).TotalSeconds;
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["phaseStartTimestamp"] = evt.Timestamp,
["phaseEndEventId"] = phaseEndEventId ?? "(still active)",
["phase"] = targetPhase,
["durationSeconds"] = durationSeconds,
["durationFormatted"] = FormatDuration(durationSeconds)
});
}
return (false, "phase not found");
}
private static string FormatDuration(double totalSeconds)
{
var ts = TimeSpan.FromSeconds(totalSeconds);
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{ts.Minutes}m {ts.Seconds}s";
return $"{ts.Seconds}s";
}
private bool EvaluateConfidenceFactor(ConfidenceFactor factor, List<EnrollmentEvent> events, Dictionary<string, object> matchedConditions)
{
if (factor.Condition.StartsWith("phase_duration >"))
{
if (int.TryParse(factor.Condition.Replace("phase_duration >", "").Trim(), out var threshold))
{
foreach (var mc in matchedConditions.Values)
{
if (mc is Dictionary<string, object> dict && dict.TryGetValue("durationSeconds", out var rawDuration))
{
var duration = Convert.ToDouble(rawDuration);
return duration > threshold;
}
}
}
}
else if (factor.Condition == "exists")
{
return matchedConditions.ContainsKey(factor.Signal);
}
else if (factor.Condition.StartsWith("count >="))
{
if (int.TryParse(factor.Condition.Replace("count >=", "").Trim(), out var threshold))
{
var count = events.Count(e => e.EventType == factor.Signal);
return count >= threshold;
}
}
return false;
}
// ===== HELPERS =====
private bool MatchesEventType(EnrollmentEvent evt, string eventType)
{
if (string.IsNullOrEmpty(eventType))
return false;
return string.Equals(evt.EventType, eventType, StringComparison.OrdinalIgnoreCase);
}
private string? GetDataFieldValue(EnrollmentEvent evt, string dataField)
{
if (evt.Data == null || string.IsNullOrEmpty(dataField))
return null;
// Support checking message field directly
if (dataField.Equals("message", StringComparison.OrdinalIgnoreCase))
return evt.Message;
// Check in Data dictionary
if (evt.Data.TryGetValue(dataField, out var value))
return value?.ToString();
// Try case-insensitive lookup
var key = evt.Data.Keys.FirstOrDefault(k => k.Equals(dataField, StringComparison.OrdinalIgnoreCase));
if (key != null)
return evt.Data[key]?.ToString();
return null;
}
private bool MatchesOperator(string fieldValue, string op, string compareValue)
{
switch (op?.ToLower())
{
case "equals":
return string.Equals(fieldValue, compareValue, StringComparison.OrdinalIgnoreCase);
case "contains":
return fieldValue.IndexOf(compareValue, StringComparison.OrdinalIgnoreCase) >= 0;
case "regex":
try
{
return Regex.IsMatch(fieldValue, compareValue, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
}
catch
{
return false;
}
case "gt":
return double.TryParse(fieldValue, out var gt1) && double.TryParse(compareValue, out var gt2) && gt1 > gt2;
case "lt":
return double.TryParse(fieldValue, out var lt1) && double.TryParse(compareValue, out var lt2) && lt1 < lt2;
case "gte":
return double.TryParse(fieldValue, out var gte1) && double.TryParse(compareValue, out var gte2) && gte1 >= gte2;
case "lte":
return double.TryParse(fieldValue, out var lte1) && double.TryParse(compareValue, out var lte2) && lte1 <= lte2;
case "exists":
return !string.IsNullOrEmpty(fieldValue);
default:
return false;
}
}
private (bool matched, object evidence) EvaluateAppInstallDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
var completionEventTypes = string.IsNullOrWhiteSpace(condition.EventType)
? new HashSet<string>(new[] { "app_install_completed", "app_install_failed" }, StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(new[] { condition.EventType }, StringComparer.OrdinalIgnoreCase);
var completionEvents = sortedEvents
.Where(e => completionEventTypes.Contains(e.EventType ?? string.Empty))
.ToList();
foreach (var completionEvent in completionEvents)
{
var appId = GetDataFieldValue(completionEvent, "appId");
var appName = GetDataFieldValue(completionEvent, "appName");
var appKey = !string.IsNullOrWhiteSpace(appId) ? appId : appName;
if (string.IsNullOrWhiteSpace(appKey))
continue;
var startEvent = sortedEvents.LastOrDefault(e =>
(string.Equals(e.EventType, "app_install_started", StringComparison.OrdinalIgnoreCase) ||
string.Equals(e.EventType, "app_install_start", StringComparison.OrdinalIgnoreCase)) &&
e.Timestamp <= completionEvent.Timestamp &&
string.Equals(GetDataFieldValue(e, "appId") ?? GetDataFieldValue(e, "appName"), appKey, StringComparison.OrdinalIgnoreCase));
if (startEvent == null)
continue;
var durationSeconds = Math.Max(0, (completionEvent.Timestamp - startEvent.Timestamp).TotalSeconds);
if (!MatchesOperator(durationSeconds.ToString(), condition.Operator, condition.Value))
continue;
return (true, new Dictionary<string, object>
{
["eventId"] = completionEvent.EventId,
["sequence"] = completionEvent.Sequence,
["startEventId"] = startEvent.EventId,
["startTimestamp"] = startEvent.Timestamp,
["endTimestamp"] = completionEvent.Timestamp,
["eventType"] = completionEvent.EventType,
["appId"] = appId ?? string.Empty,
["appName"] = appName ?? appKey,
["durationSeconds"] = durationSeconds,
["durationFormatted"] = FormatDuration(durationSeconds)
});
}
return (false, "no app install duration matched");
}
/// <summary>
/// Generic event correlation: finds pairs of events (A before B) sharing
/// the same value in a join field, with optional filters on each event
/// and optional time window constraint.
///
/// Condition fields used:
/// EventType = Event A type (required)
/// CorrelateEventType = Event B type (required)
/// JoinField = field that must match in both events (required, e.g. "appId")
/// TimeWindowSeconds = max seconds between A and B (optional, 0/null = unlimited)
/// DataField/Operator/Value = optional filter on Event B
/// EventAFilterField/Operator/Value = optional filter on Event A
/// </summary>
private (bool matched, object evidence) EvaluateEventCorrelationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
if (string.IsNullOrEmpty(condition.EventType) ||
string.IsNullOrEmpty(condition.CorrelateEventType) ||
string.IsNullOrEmpty(condition.JoinField))
{
_logger.LogWarning($"event_correlation condition '{condition.Signal}' missing required fields (EventType, CorrelateEventType, JoinField)");
return (false, "event_correlation requires EventType, CorrelateEventType, and JoinField");
}
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
// Collect Event A candidates with optional filter
var eventAList = sortedEvents
.Where(e => MatchesEventType(e, condition.EventType))
.Where(e =>
{
if (string.IsNullOrEmpty(condition.EventAFilterField))
return true;
var val = GetDataFieldValue(e, condition.EventAFilterField);
return val != null && MatchesOperator(val, condition.EventAFilterOperator ?? "exists", condition.EventAFilterValue ?? "");
})
.ToList();
// Collect Event B candidates with optional DataField/Operator/Value filter
var eventBList = sortedEvents
.Where(e => MatchesEventType(e, condition.CorrelateEventType))
.Where(e =>
{
if (string.IsNullOrEmpty(condition.DataField))
return true;
var val = GetDataFieldValue(e, condition.DataField);
return val != null && MatchesOperator(val, condition.Operator ?? "exists", condition.Value ?? "");
})
.ToList();
if (!eventAList.Any() || !eventBList.Any())
return (false, "no matching event pairs found");
// Index Event A by join field value for O(1) lookup
var eventAByJoinKey = eventAList
.Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.JoinField) })
.Where(x => !string.IsNullOrEmpty(x.Key))
.GroupBy(x => x.Key!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Select(x => x.Event).ToList(), StringComparer.OrdinalIgnoreCase);
// For each Event B, find a matching Event A (same join key, A before B, within time window)
var matchedPairs = new List<Dictionary<string, object>>();
var matchedJoinKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var eventB in eventBList)
{
var bKey = GetDataFieldValue(eventB, condition.JoinField);
if (string.IsNullOrEmpty(bKey) || !eventAByJoinKey.ContainsKey(bKey))
continue;
// Find the latest Event A that occurred before Event B
var matchingA = eventAByJoinKey[bKey]
.Where(a => a.Timestamp < eventB.Timestamp ||
(a.Timestamp == eventB.Timestamp && a.Sequence < eventB.Sequence))
.Where(a =>
{
if (condition.TimeWindowSeconds == null || condition.TimeWindowSeconds <= 0)
return true;
return (eventB.Timestamp - a.Timestamp).TotalSeconds <= condition.TimeWindowSeconds.Value;
})
.OrderByDescending(a => a.Timestamp)
.ThenByDescending(a => a.Sequence)
.FirstOrDefault();
if (matchingA == null)
continue;
// One match per join key to avoid duplicates
if (matchedJoinKeys.Contains(bKey))
continue;
matchedJoinKeys.Add(bKey);
// Build evidence for this correlated pair
var pair = new Dictionary<string, object>
{
["joinField"] = condition.JoinField,
["joinValue"] = bKey,
["eventA_eventId"] = matchingA.EventId ?? string.Empty,
["eventA_eventType"] = matchingA.EventType ?? string.Empty,
["eventA_timestamp"] = matchingA.Timestamp,
["eventA_sequence"] = matchingA.Sequence,
["eventA_message"] = matchingA.Message ?? string.Empty,
["eventB_eventId"] = eventB.EventId ?? string.Empty,
["eventB_eventType"] = eventB.EventType ?? string.Empty,
["eventB_timestamp"] = eventB.Timestamp,
["eventB_sequence"] = eventB.Sequence,
["eventB_message"] = eventB.Message ?? string.Empty,
["timeDeltaSeconds"] = (eventB.Timestamp - matchingA.Timestamp).TotalSeconds
};
// Add well-known data fields from both events
AddDataFieldsToEvidence(pair, matchingA, "eventA_", condition.JoinField);
AddDataFieldsToEvidence(pair, eventB, "eventB_", condition.JoinField);
matchedPairs.Add(pair);
}
if (!matchedPairs.Any())
return (false, "no correlated event pairs found");
// Single match: flat dictionary; multiple: first as primary + allMatches list
var primary = matchedPairs[0];
primary["totalMatches"] = matchedPairs.Count;
if (matchedPairs.Count > 1)
{
primary["allMatches"] = matchedPairs;
}
return (true, primary);
}
/// <summary>
/// Adds well-known data fields from an event to the evidence dictionary with a prefix.
/// </summary>
private void AddDataFieldsToEvidence(Dictionary<string, object> evidence, EnrollmentEvent evt, string prefix, string joinField)
{
if (evt.Data == null) return;
var knownFields = new[] { "appId", "appName", "errorPatternId", "errorDetail", "errorCode", "status" };
foreach (var field in knownFields)
{
var val = GetDataFieldValue(evt, field);
if (val != null)
evidence[$"{prefix}{field}"] = val;
}
}
}
}
@@ -0,0 +1,275 @@
"use client";
import { AnalyzeRule, RuleForm, getSeverityColor, getCategoryColor } from "../types";
import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle";
import AnalyzeRuleFormFields from "./AnalyzeRuleFormFields";
interface AnalyzeRuleCardProps {
rule: AnalyzeRule;
isExpanded: boolean;
isEditing: boolean;
editForm: RuleForm;
setEditForm: (f: RuleForm) => void;
saving: boolean;
togglingRuleId: string | null;
deletingRuleId: string | null;
jsonModeEdit: boolean;
jsonText: string;
jsonError: string | null;
onToggle: () => void;
onToggleEnabled: (rule: AnalyzeRule) => void;
onStartEditing: (rule: AnalyzeRule) => void;
onSaveEdit: (rule: AnalyzeRule, formOverride?: RuleForm) => void;
onCancelEdit: () => void;
onDelete: (rule: AnalyzeRule) => void;
onExport: (rule: AnalyzeRule) => void;
onSetJsonModeEdit: (mode: boolean) => void;
onSetJsonText: (text: string) => void;
onSetJsonError: (error: string | null) => void;
}
export default function AnalyzeRuleCard({
rule, isExpanded, isEditing, editForm, setEditForm, saving,
togglingRuleId, deletingRuleId,
jsonModeEdit, jsonText, jsonError,
onToggle, onToggleEnabled, onStartEditing, onSaveEdit, onCancelEdit,
onDelete, onExport,
onSetJsonModeEdit, onSetJsonText, onSetJsonError,
}: AnalyzeRuleCardProps) {
const sevColor = getSeverityColor(rule.severity);
const catColor = getCategoryColor(rule.category);
const canEdit = !rule.isBuiltIn && !rule.isCommunity;
return (
<div
className={`bg-white rounded-lg shadow border transition-all ${
isExpanded ? "border-indigo-300 ring-1 ring-indigo-200" : "border-gray-200 hover:border-gray-300"
}`}
>
{/* Collapsed Header */}
<div className="p-4 cursor-pointer select-none" onClick={() => { if (isEditing) return; onToggle(); }}>
<div className="flex items-center space-x-4">
<button onClick={(e) => { e.stopPropagation(); onToggleEnabled(rule); }} disabled={togglingRuleId === rule.ruleId} className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${togglingRuleId === rule.ruleId ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${rule.enabled ? "bg-green-500" : "bg-gray-300"}`} title={rule.enabled ? "Disable rule" : "Enable rule"}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${sevColor.bg} ${sevColor.text} flex-shrink-0`}>
<span className={`w-1.5 h-1.5 rounded-full ${sevColor.dot} mr-1.5`}></span>
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
</span>
<span className="text-xs font-mono text-gray-400 flex-shrink-0 hidden sm:inline">{rule.ruleId}</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">{rule.title}</h3>
</div>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${catColor.bg} ${catColor.text} flex-shrink-0`}>
{rule.category.charAt(0).toUpperCase() + rule.category.slice(1)}
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${rule.isBuiltIn ? "bg-blue-50 text-blue-600 border border-blue-200" : rule.isCommunity ? "bg-green-100 text-green-700" : "bg-purple-50 text-purple-600 border border-purple-200"}`}>
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
</span>
<span className="text-xs text-gray-500 flex-shrink-0 hidden md:inline" title="Confidence Threshold">Threshold: {rule.confidenceThreshold}%</span>
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
{/* Expanded Details (read-only) */}
{isExpanded && !isEditing && (
<div className="border-t border-gray-200 p-6 space-y-6">
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span><span className="font-medium text-gray-700">Version:</span> {rule.version}</span>
<span><span className="font-medium text-gray-700">Author:</span> {rule.author}</span>
<span><span className="font-medium text-gray-700">Trigger:</span> {(rule.trigger || "single").charAt(0).toUpperCase() + (rule.trigger || "single").slice(1)}</span>
<span><span className="font-medium text-gray-700">Created:</span> {new Date(rule.createdAt).toLocaleDateString()}</span>
<span><span className="font-medium text-gray-700">Updated:</span> {new Date(rule.updatedAt).toLocaleDateString()}</span>
<span className="text-xs font-mono text-gray-400 sm:hidden">{rule.ruleId}</span>
</div>
{rule.tags && rule.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{rule.tags.map((tag, idx) => (<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600">#{tag}</span>))}
</div>
)}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-1">Description</h4>
<p className="text-sm text-gray-600 leading-relaxed">{rule.description}</p>
</div>
{rule.conditions.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Conditions ({rule.conditions.length})</h4>
<div className="space-y-2">
{rule.conditions.map((condition, idx) => (
<div key={idx} className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm">
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="font-medium text-gray-800">{condition.signal}</span>
{condition.required && (<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-medium">Required</span>)}
</div>
<div className="text-gray-500 space-y-0.5">
<p>
<span className="text-gray-600 font-medium">Source:</span> {condition.source}
{condition.eventType && (<span> | <span className="text-gray-600 font-medium">Event Type:</span> {condition.eventType}</span>)}
{condition.dataField && (<span> | <span className="text-gray-600 font-medium">Field:</span> <code className="bg-gray-200 px-1 rounded text-xs">{condition.dataField}</code></span>)}
</p>
<p>
<span className="text-gray-600 font-medium">Operator:</span> <code className="bg-gray-200 px-1 rounded text-xs">{condition.operator}</code>{" "}
<span className="text-gray-600 font-medium">Value:</span> <code className="bg-gray-200 px-1 rounded text-xs">{condition.value}</code>
</p>
</div>
</div>
))}
</div>
</div>
)}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Confidence Scoring</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3">
<div className="flex items-center space-x-6 text-sm">
<span><span className="text-gray-600 font-medium">Base Confidence:</span> <span className="font-semibold text-gray-900">{rule.baseConfidence}%</span></span>
<span><span className="text-gray-600 font-medium">Threshold:</span> <span className="font-semibold text-gray-900">{rule.confidenceThreshold}%</span></span>
</div>
{rule.confidenceFactors.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Confidence Factors</p>
<div className="space-y-1">
{rule.confidenceFactors.map((factor, idx) => (
<div key={idx} className="flex items-center justify-between text-sm bg-white border border-gray-100 rounded px-3 py-1.5">
<div><span className="font-medium text-gray-700">{factor.signal}</span><span className="text-gray-400 mx-2">-</span><span className="text-gray-500">{factor.condition}</span></div>
<span className={`font-semibold ${factor.weight > 0 ? "text-green-600" : "text-red-600"}`}>{factor.weight > 0 ? "+" : ""}{factor.weight}%</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{rule.explanation && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-1">Explanation</h4>
<p className="text-sm text-gray-600 leading-relaxed bg-blue-50 border border-blue-200 rounded-lg p-3">{rule.explanation}</p>
</div>
)}
{rule.remediation.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Remediation Steps</h4>
<div className="space-y-3">
{rule.remediation.map((rem, idx) => (
<div key={idx} className="bg-green-50 border border-green-200 rounded-lg p-4">
<h5 className="text-sm font-semibold text-green-800 mb-2">{rem.title}</h5>
<ol className="list-decimal list-inside space-y-1">
{rem.steps.map((step, sIdx) => (<li key={sIdx} className="text-sm text-green-700">{step}</li>))}
</ol>
</div>
))}
</div>
</div>
)}
{rule.relatedDocs.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Related Documentation</h4>
<div className="flex flex-wrap gap-2">
{rule.relatedDocs.map((doc, idx) => (
<a key={idx} href={doc.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center space-x-1.5 px-3 py-1.5 bg-indigo-50 border border-indigo-200 rounded-lg text-sm text-indigo-700 hover:bg-indigo-100 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
<span>{doc.title}</span>
</a>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
<button onClick={() => onExport(rule)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center space-x-2" title="Export rule as JSON">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
<span>Export</span>
</button>
{canEdit && (
<button onClick={() => onStartEditing(rule)} className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2" title="Edit rule">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span>Edit</span>
</button>
)}
{!rule.isBuiltIn && !rule.isCommunity && (
<button onClick={() => onDelete(rule)} disabled={deletingRuleId === rule.ruleId} className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2" title="Delete rule">
{deletingRuleId === rule.ruleId ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Deleting...</span></>) : (<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg><span>Delete</span></>)}
</button>
)}
</div>
</div>
)}
{/* Edit Form */}
{isExpanded && isEditing && (
<div className="border-t border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<h4 className="text-sm font-semibold text-gray-900">
Editing: {rule.ruleId}
</h4>
</div>
{/* JSON Mode Toggle */}
<JsonModeToggleButtons
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { onSetJsonText(JSON.stringify(editForm, null, 2)); }
onSetJsonModeEdit(mode);
onSetJsonError(null);
}}
/>
</div>
<FormJsonToggle
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { onSetJsonText(JSON.stringify(editForm, null, 2)); }
onSetJsonModeEdit(mode);
onSetJsonError(null);
}}
jsonText={jsonText}
onJsonTextChange={(text) => { onSetJsonText(text); onSetJsonError(null); }}
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm({ ...editForm, ...parsed });
onSetJsonModeEdit(false);
onSetJsonError(null);
} catch (e) {
onSetJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}}
textareaRows={30}
description='Edit the rule as JSON. All fields are supported including <code class="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.'
>
<AnalyzeRuleFormFields form={editForm} setForm={setEditForm} showRuleId={false} />
</FormJsonToggle>
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
<button onClick={onCancelEdit} disabled={saving} className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium">Cancel</button>
<button onClick={() => {
if (jsonModeEdit) {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
onSetJsonError(null);
onSaveEdit(rule, { ...editForm, ...parsed });
} catch (e) {
onSetJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
} else {
onSaveEdit(rule);
}
}} disabled={saving} className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
{saving ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>) : (<span>Save Changes</span>)}
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,247 @@
"use client";
import {
RuleForm, RuleCondition,
CATEGORIES, SEVERITIES, TRIGGERS, OPERATORS, SOURCES,
EMPTY_CONDITION, EMPTY_FACTOR,
} from "../types";
interface AnalyzeRuleFormFieldsProps {
form: RuleForm;
setForm: (f: RuleForm) => void;
showRuleId: boolean;
}
export default function AnalyzeRuleFormFields({ form, setForm, showRuleId }: AnalyzeRuleFormFieldsProps) {
return (
<div className="space-y-5">
{/* Basic Fields */}
<div className={`grid grid-cols-1 ${showRuleId ? "sm:grid-cols-2" : ""} gap-4`}>
{showRuleId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rule ID <span className="text-red-500">*</span></label>
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title <span className="text-red-500">*</span></label>
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe what this rule detects..." rows={2} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Severity</label>
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
{SEVERITIES.map((s) => (<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
{CATEGORIES.map((cat) => (<option key={cat} value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Type</label>
<select value={form.trigger} onChange={(e) => setForm({ ...form, trigger: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
{TRIGGERS.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
</div>
{/* Conditions */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Conditions</label>
<button type="button" onClick={() => setForm({ ...form, conditions: [...form.conditions, { ...EMPTY_CONDITION }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Condition</button>
</div>
<div className="space-y-3">
{form.conditions.map((cond, idx) => {
const isCorrelation = cond.source === "event_correlation";
const updateCond = (patch: Partial<RuleCondition>) => {
const c = [...form.conditions];
c[idx] = { ...c[idx], ...patch };
setForm({ ...form, conditions: c });
};
return (
<div key={idx} className={`border rounded-lg p-3 space-y-2 ${isCorrelation ? "bg-indigo-50 border-indigo-200" : "bg-gray-50 border-gray-200"}`}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500">
Condition {idx + 1}
{isCorrelation && <span className="ml-2 px-1.5 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs">event_correlation</span>}
</span>
{form.conditions.length > 1 && (
<button type="button" onClick={() => setForm({ ...form, conditions: form.conditions.filter((_, i) => i !== idx) })} className="text-xs text-red-500 hover:text-red-700">Remove</button>
)}
</div>
{/* Row 1: Signal, Source, Event Type A */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input type="text" value={cond.signal} onChange={(e) => updateCond({ signal: e.target.value })} placeholder="Signal name" autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<select value={cond.source} onChange={(e) => updateCond({ source: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
{SOURCES.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
<input type="text" value={cond.eventType} onChange={(e) => updateCond({ eventType: e.target.value })} placeholder={isCorrelation ? "Event A type (e.g. app_install_completed)" : "Event type"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
{/* Row 2: Data field / Operator / Value / Required */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
<input type="text" value={cond.dataField} onChange={(e) => updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<select value={cond.operator} onChange={(e) => updateCond({ operator: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
</select>
<input type="text" value={cond.value} onChange={(e) => updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<label className="flex items-center space-x-2 text-sm text-gray-700">
<input type="checkbox" checked={cond.required} onChange={(e) => updateCond({ required: e.target.checked })} className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<span>Required</span>
</label>
</div>
{/* event_correlation extra fields */}
{isCorrelation && (
<div className="pt-2 border-t border-indigo-200 space-y-2">
<p className="text-xs font-medium text-indigo-600">Correlation settings</p>
{/* Correlate Event Type + Join Field + Time Window */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">Event B type <span className="text-red-500">*</span></label>
<input type="text" value={cond.correlateEventType ?? ""} onChange={(e) => updateCond({ correlateEventType: e.target.value })} placeholder="e.g. app_install_failed" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Join field <span className="text-red-500">*</span></label>
<input type="text" value={cond.joinField ?? ""} onChange={(e) => updateCond({ joinField: e.target.value })} placeholder="e.g. appId" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Time window (seconds, optional)</label>
<input type="number" min={0} value={cond.timeWindowSeconds ?? ""} onChange={(e) => updateCond({ timeWindowSeconds: e.target.value === "" ? null : parseInt(e.target.value) || 0 })} placeholder="e.g. 300" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
</div>
{/* Event A Filter */}
<div>
<label className="block text-xs text-gray-500 mb-1">Event A filter (optional)</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input type="text" value={cond.eventAFilterField ?? ""} onChange={(e) => updateCond({ eventAFilterField: e.target.value })} placeholder="Filter field on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<select value={cond.eventAFilterOperator ?? "equals"} onChange={(e) => updateCond({ eventAFilterOperator: e.target.value })} className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
</select>
<input type="text" value={cond.eventAFilterValue ?? ""} onChange={(e) => updateCond({ eventAFilterValue: e.target.value })} placeholder="Filter value on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Confidence Scoring */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Base Confidence (%)</label>
<input type="number" min={0} max={100} value={form.baseConfidence} onChange={(e) => setForm({ ...form, baseConfidence: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confidence Threshold (%)</label>
<input type="number" min={0} max={100} value={form.confidenceThreshold} onChange={(e) => setForm({ ...form, confidenceThreshold: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
</div>
</div>
{/* Confidence Factors */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Confidence Factors</label>
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: [...form.confidenceFactors, { ...EMPTY_FACTOR }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Factor</button>
</div>
{form.confidenceFactors.length === 0 ? (
<p className="text-xs text-gray-400 italic">No confidence factors. Click &quot;+ Add Factor&quot; to add one.</p>
) : (
<div className="space-y-2">
{form.confidenceFactors.map((factor, idx) => (
<div key={idx} className="flex items-center gap-2">
<input type="text" value={factor.signal} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], signal: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Signal" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={factor.condition} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], condition: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Condition" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="number" value={factor.weight} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], weight: parseInt(e.target.value) || 0 }; setForm({ ...form, confidenceFactors: f }); }} className="w-20 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<span className="text-xs text-gray-500">%</span>
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: form.confidenceFactors.filter((_, i) => i !== idx) })} className="text-red-400 hover:text-red-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Explanation */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Explanation</label>
<textarea value={form.explanation} onChange={(e) => setForm({ ...form, explanation: e.target.value })} placeholder="Detailed explanation shown when this rule fires..." rows={3} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" />
</div>
{/* Remediation Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Remediation Steps</label>
<button type="button" onClick={() => setForm({ ...form, remediation: [...form.remediation, { title: "", steps: [""] }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Section</button>
</div>
{form.remediation.length === 0 ? (
<p className="text-xs text-gray-400 italic">No remediation steps. Click &quot;+ Add Section&quot; to add one.</p>
) : (
<div className="space-y-3">
{form.remediation.map((rem, rIdx) => (
<div key={rIdx} className="bg-green-50 border border-green-200 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<input type="text" value={rem.title} onChange={(e) => { const r = [...form.remediation]; r[rIdx] = { ...r[rIdx], title: e.target.value }; setForm({ ...form, remediation: r }); }} placeholder="Section title" autoComplete="off" className="flex-1 px-3 py-1.5 border border-green-300 rounded text-sm text-gray-900 placeholder-gray-400 bg-white focus:outline-none focus:ring-1 focus:ring-green-500" />
<button type="button" onClick={() => setForm({ ...form, remediation: form.remediation.filter((_, i) => i !== rIdx) })} className="ml-2 text-red-400 hover:text-red-600 text-xs">Remove</button>
</div>
{rem.steps.map((step, sIdx) => (
<div key={sIdx} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-5 text-right">{sIdx + 1}.</span>
<input type="text" value={step} onChange={(e) => { const r = [...form.remediation]; const steps = [...r[rIdx].steps]; steps[sIdx] = e.target.value; r[rIdx] = { ...r[rIdx], steps }; setForm({ ...form, remediation: r }); }} placeholder="Step description" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
{rem.steps.length > 1 && (
<button type="button" onClick={() => { const r = [...form.remediation]; r[rIdx] = { ...r[rIdx], steps: r[rIdx].steps.filter((_, i) => i !== sIdx) }; setForm({ ...form, remediation: r }); }} className="text-red-400 hover:text-red-600">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
)}
</div>
))}
<button type="button" onClick={() => { const r = [...form.remediation]; r[rIdx] = { ...r[rIdx], steps: [...r[rIdx].steps, ""] }; setForm({ ...form, remediation: r }); }} className="text-xs text-green-600 hover:text-green-800 font-medium">+ Add Step</button>
</div>
))}
</div>
)}
</div>
{/* Related Docs */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Related Documentation</label>
<button type="button" onClick={() => setForm({ ...form, relatedDocs: [...form.relatedDocs, { title: "", url: "" }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Link</button>
</div>
{form.relatedDocs.length === 0 ? (
<p className="text-xs text-gray-400 italic">No related docs. Click &quot;+ Add Link&quot; to add one.</p>
) : (
<div className="space-y-2">
{form.relatedDocs.map((doc, idx) => (
<div key={idx} className="flex items-center gap-2">
<input type="text" value={doc.title} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], title: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="Link title" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={doc.url} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], url: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="https://..." autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<button type="button" onClick={() => setForm({ ...form, relatedDocs: form.relatedDocs.filter((_, i) => i !== idx) })} className="text-red-400 hover:text-red-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
export interface RelatedDoc {
title: string;
url: string;
}
export interface RemediationStep {
title: string;
steps: string[];
}
export interface ConfidenceFactor {
signal: string;
condition: string;
weight: number;
}
export interface RuleCondition {
signal: string;
source: string;
eventType: string;
dataField: string;
operator: string;
value: string;
required: boolean;
// event_correlation fields
correlateEventType?: string;
joinField?: string;
timeWindowSeconds?: number | null;
eventAFilterField?: string;
eventAFilterOperator?: string;
eventAFilterValue?: string;
}
export interface AnalyzeRule {
ruleId: string;
title: string;
description: string;
severity: string;
category: string;
trigger: string;
version: string;
author: string;
enabled: boolean;
isBuiltIn: boolean;
isCommunity: boolean;
conditions: RuleCondition[];
baseConfidence: number;
confidenceFactors: ConfidenceFactor[];
confidenceThreshold: number;
explanation: string;
remediation: RemediationStep[];
relatedDocs: RelatedDoc[];
tags: string[];
createdAt: string;
updatedAt: string;
}
export interface RuleForm {
ruleId: string;
title: string;
description: string;
severity: string;
category: string;
trigger: string;
explanation: string;
baseConfidence: number;
confidenceThreshold: number;
conditions: RuleCondition[];
confidenceFactors: ConfidenceFactor[];
remediation: RemediationStep[];
relatedDocs: RelatedDoc[];
}
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
export const SEVERITIES = ["info", "warning", "high", "critical"] as const;
export const TRIGGERS = ["single", "correlation"] as const;
export const OPERATORS = ["equals", "contains", "regex", "gt", "lt", "gte", "lte", "exists", "count_gte"] as const;
export const SOURCES = ["event_type", "event_data", "phase_duration", "event_count", "event_correlation"] as const;
export const SEVERITY_COLORS: Record<string, { bg: string; text: string; border: string; dot: string }> = {
critical: { bg: "bg-red-100", text: "text-red-800", border: "border-red-300", dot: "bg-red-500" },
high: { bg: "bg-orange-100", text: "text-orange-800", border: "border-orange-300", dot: "bg-orange-500" },
warning: { bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-300", dot: "bg-yellow-500" },
info: { bg: "bg-blue-100", text: "text-blue-800", border: "border-blue-300", dot: "bg-blue-500" },
};
export const CATEGORY_COLORS: Record<string, { bg: string; text: string }> = {
network: { bg: "bg-blue-100", text: "text-blue-700" },
identity: { bg: "bg-purple-100", text: "text-purple-700" },
apps: { bg: "bg-orange-100", text: "text-orange-700" },
device: { bg: "bg-gray-100", text: "text-gray-700" },
esp: { bg: "bg-teal-100", text: "text-teal-700" },
enrollment: { bg: "bg-indigo-100", text: "text-indigo-700" },
};
export function getSeverityColor(severity: string) {
return SEVERITY_COLORS[severity.toLowerCase()] || SEVERITY_COLORS.info;
}
export function getCategoryColor(category: string) {
return CATEGORY_COLORS[category.toLowerCase()] || { bg: "bg-gray-100", text: "text-gray-700" };
}
export const EMPTY_CONDITION: RuleCondition = {
signal: "",
source: "event_type",
eventType: "",
dataField: "",
operator: "contains",
value: "",
required: true,
correlateEventType: "",
joinField: "",
timeWindowSeconds: null,
eventAFilterField: "",
eventAFilterOperator: "equals",
eventAFilterValue: "",
};
export const EMPTY_FACTOR: ConfidenceFactor = {
signal: "",
condition: "",
weight: 10,
};
export const EMPTY_FORM: RuleForm = {
ruleId: "",
title: "",
description: "",
severity: "warning",
category: "device",
trigger: "single",
explanation: "",
baseConfidence: 50,
confidenceThreshold: 40,
conditions: [{ ...EMPTY_CONDITION }],
confidenceFactors: [],
remediation: [],
relatedDocs: [],
};
export function ruleToForm(rule: AnalyzeRule): RuleForm {
return {
ruleId: rule.ruleId,
title: rule.title,
description: rule.description || "",
severity: rule.severity,
category: rule.category,
trigger: rule.trigger || "single",
explanation: rule.explanation || "",
baseConfidence: rule.baseConfidence,
confidenceThreshold: rule.confidenceThreshold,
conditions: rule.conditions.length > 0 ? rule.conditions.map(c => ({ ...c })) : [{ ...EMPTY_CONDITION }],
confidenceFactors: rule.confidenceFactors.map(f => ({ ...f })),
remediation: rule.remediation.map(r => ({ title: r.title, steps: [...r.steps] })),
relatedDocs: rule.relatedDocs.map(d => ({ ...d })),
};
}
@@ -0,0 +1,104 @@
"use client";
interface DeleteConfirmModalProps {
sessionToDelete: { sessionId: string; tenantId: string; deviceName?: string };
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteConfirmModal({ sessionToDelete, onConfirm, onCancel }: DeleteConfirmModalProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Delete Session</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
This action is <span className="font-semibold text-red-600">irreversible</span>!
</p>
<p className="text-sm text-gray-700 mb-2">
The session <span className="font-mono text-xs">{sessionToDelete.sessionId}</span> for device <span className="font-semibold">{sessionToDelete.deviceName || 'Unknown'}</span> and all associated events will be permanently deleted.
</p>
<p className="text-sm text-gray-600">
Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
</div>
);
}
interface BlockConfirmModalProps {
sessionToBlock: { serialNumber: string; tenantId: string; deviceName?: string };
blockingDevice: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function BlockConfirmModal({ sessionToBlock, blockingDevice, onConfirm, onCancel }: BlockConfirmModalProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Block Device</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
Device <span className="font-semibold">{sessionToBlock.deviceName || sessionToBlock.serialNumber}</span> (Serial: <span className="font-mono text-xs">{sessionToBlock.serialNumber}</span>) will be blocked for <span className="font-semibold">24 hours</span>.
</p>
<p className="text-sm text-gray-600">
The agent will stop uploading data while blocked. Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={blockingDevice}
className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors disabled:opacity-50"
>
{blockingDevice ? 'Blocking...' : 'Block'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,385 @@
"use client";
import { useRouter } from "next/navigation";
import { Session } from "../types";
interface SessionTableProps {
sessions: Session[];
filteredSessions: Session[];
sortedSessions: Session[];
paginatedSessions: Session[];
searchQuery: string;
onSearchQueryChange: (query: string) => void;
statusFilter: string | null;
onStatusFilterChange: (status: string | null) => void;
sortColumn: keyof Session | null;
sortDirection: "asc" | "desc";
onSort: (column: keyof Session) => void;
currentPage: number;
totalPages: number;
onPreviousPage: () => void;
onNextPage: () => void;
adminMode: boolean;
galacticAdminMode: boolean;
blockedDevicesSet: Set<string>;
isPreviewBlocked: boolean;
user: { isGalacticAdmin?: boolean } | null;
onDeleteSession: (sessionId: string, tenantId: string, deviceName?: string) => void;
onBlockDevice: (serialNumber: string, tenantId: string, deviceName?: string) => void;
}
export function SessionTable({
sessions,
filteredSessions,
sortedSessions,
paginatedSessions,
searchQuery,
onSearchQueryChange,
statusFilter,
onStatusFilterChange,
sortColumn,
sortDirection,
onSort,
currentPage,
totalPages,
onPreviousPage,
onNextPage,
adminMode,
galacticAdminMode,
blockedDevicesSet,
isPreviewBlocked,
user,
onDeleteSession,
onBlockDevice,
}: SessionTableProps) {
const router = useRouter();
return (
<div className="mt-8 bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<h2 className="text-xl font-semibold text-gray-900">
Sessions ({sessions.length})
{filteredSessions.length !== sessions.length && (
<span className="text-sm text-gray-500 ml-2">
({filteredSessions.length} filtered)
</span>
)}
</h2>
{isPreviewBlocked ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200 shrink-0">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Preview approval pending
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200 shrink-0">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
Preview approved
</span>
)}
</div>
{/* Search Input */}
<div className="mb-4 relative">
<input
type="text"
placeholder="Search by device, serial, model, status, session ID, or duration (e.g., >30 for >30min)"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
{searchQuery && (
<button
onClick={() => onSearchQueryChange("")}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Status Filter Badges */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
{(["Succeeded", "InProgress", "Pending", "Failed"] as const).map((status) => {
const config: Record<string, { bg: string; bgActive: string; text: string; label: string }> = {
Succeeded: { bg: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100", bgActive: "bg-green-600 text-white border-green-600", text: "text-green-600", label: "Succeeded" },
InProgress: { bg: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100", bgActive: "bg-blue-600 text-white border-blue-600", text: "text-blue-600", label: "In Progress" },
Pending: { bg: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100", bgActive: "bg-amber-500 text-white border-amber-500", text: "text-amber-600", label: "Pending" },
Failed: { bg: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100", bgActive: "bg-red-600 text-white border-red-600", text: "text-red-600", label: "Failed" },
};
const c = config[status];
const count = sessions.filter(s => s.status === status).length;
const isActive = statusFilter === status;
return (
<button
key={status}
onClick={() => onStatusFilterChange(isActive ? null : status)}
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold border transition-colors cursor-pointer ${isActive ? c.bgActive : c.bg}`}
>
{c.label}
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${isActive ? "bg-white/25" : "bg-black/5"}`}>
{count}
</span>
</button>
);
})}
{statusFilter && (
<button
onClick={() => onStatusFilterChange(null)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer"
title="Clear filter"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear
</button>
)}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<SortableHeader column="deviceName" currentSort={sortColumn} direction={sortDirection} onSort={onSort}>
Device
</SortableHeader>
{galacticAdminMode && (
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tenant ID
</th>
)}
<SortableHeader column="model" currentSort={sortColumn} direction={sortDirection} onSort={onSort}>
Model
</SortableHeader>
<SortableHeader column="status" currentSort={sortColumn} direction={sortDirection} onSort={onSort}>
Status
</SortableHeader>
<SortableHeader column="eventCount" currentSort={sortColumn} direction={sortDirection} onSort={onSort} className="px-3">
Events
</SortableHeader>
<SortableHeader column="durationSeconds" currentSort={sortColumn} direction={sortDirection} onSort={onSort} className="px-3">
Duration
</SortableHeader>
<SortableHeader column="startedAt" currentSort={sortColumn} direction={sortDirection} onSort={onSort} className="px-3">
Started
</SortableHeader>
{adminMode && (
<th scope="col" className="pl-3 pr-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedSessions.length === 0 ? (
<tr>
<td colSpan={(galacticAdminMode ? 1 : 0) + (adminMode ? 7 : 6)} className="px-6 py-8 text-center text-gray-500">
No sessions found matching your search.
</td>
</tr>
) : (
paginatedSessions.map((session) => (
<tr
key={session.sessionId}
onClick={() => router.push(`/sessions/${session.sessionId}`)}
className="hover:bg-gray-50 cursor-pointer transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{session.deviceName || session.serialNumber}
</div>
<div className="text-sm text-gray-500">
{session.serialNumber}
</div>
</td>
{galacticAdminMode && (
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(session.tenantId);
}}
className="group flex items-center gap-1 text-xs font-mono text-gray-600 hover:text-blue-600 transition-colors"
title={session.tenantId}
>
<span>{session.tenantId.split('-').slice(0, 2).join('-')}...</span>
<svg className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</td>
)}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{session.manufacturer || "Unknown manufacturer"}
</div>
<div className="text-sm text-gray-500">
{session.model || "Unknown model"}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<StatusBadge status={session.status} failureReason={session.failureReason} />
{session.isHybridJoin && (
<span
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-purple-100 text-purple-800"
title="Hybrid Azure AD Join"
>
Hybrid
</span>
)}
{blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) && (
<span
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-orange-100 text-orange-800"
title="Device is currently blocked"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Blocked
</span>
)}
</div>
</td>
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{session.eventCount}
</td>
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{Math.round(session.durationSeconds / 60)} min
</td>
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(session.startedAt).toLocaleString()}
</td>
{adminMode && (
<td className="pl-3 pr-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
{galacticAdminMode && user?.isGalacticAdmin && (
<button
onClick={(e) => {
e.stopPropagation();
onBlockDevice(session.serialNumber, session.tenantId, session.deviceName || session.serialNumber);
}}
className="text-orange-500 hover:text-orange-700 transition-colors"
title="Device blocken"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.sessionId, session.tenantId, session.deviceName || session.serialNumber);
}}
className="text-red-600 hover:text-red-900 transition-colors"
title="Session löschen"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
Page {currentPage} of {totalPages} ({sortedSessions.length} total sessions)
</div>
<div className="flex gap-2">
<button
onClick={onPreviousPage}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
← Previous
</button>
<button
onClick={onNextPage}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next →
</button>
</div>
</div>
)}
</div>
);
}
function SortableHeader({
column,
currentSort,
direction,
onSort,
children,
className,
}: {
column: keyof Session;
currentSort: keyof Session | null;
direction: "asc" | "desc";
onSort: (column: keyof Session) => void;
children: React.ReactNode;
className?: string;
}) {
const isActive = currentSort === column;
return (
<th
onClick={() => onSort(column)}
className={`py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors select-none ${className ?? "px-6"}`}
>
<div className="flex items-center gap-2">
{children}
<span className="text-gray-400">
{isActive ? (direction === "asc" ? "↑" : "↓") : "↕"}
</span>
</div>
</th>
);
}
function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out");
return (
<span
className={`px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full ${config.color}`}
title={failureReason || undefined}
>
{config.text}
{isTimeout && (
<span title={failureReason} className="inline-flex items-center">
⏱️
</span>
)}
</span>
);
}
@@ -0,0 +1,313 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useTenant } from "../../../contexts/TenantContext";
import { useAuth } from "../../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
export function SettingsMenu({
apiStatus,
onCheckHealth,
adminMode,
onAdminModeChange,
galacticAdminMode,
onGalacticAdminModeChange,
user,
}: {
apiStatus: "unchecked" | "checking" | "healthy" | "error";
onCheckHealth: () => void;
adminMode: boolean;
onAdminModeChange: (enabled: boolean) => void;
galacticAdminMode: boolean;
onGalacticAdminModeChange: (enabled: boolean) => void;
user: { displayName: string; email: string; isGalacticAdmin?: boolean } | null;
}) {
const [isOpen, setIsOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const fetchAuditLogs = async () => {
setLoadingLogs(true);
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/audit/logs?tenantId=${tenantId}`, getAccessToken);
if (response.ok) {
const data = await response.json();
setAuditLogs(data.logs || []);
}
} catch (error) {
if (error instanceof TokenExpiredError) {
console.error('Session expired:', error.message);
} else {
console.error("Failed to fetch audit logs:", error);
}
} finally {
setLoadingLogs(false);
}
};
const statusColors = {
unchecked: "text-gray-500",
checking: "text-yellow-600",
healthy: "text-green-600",
error: "text-red-600",
};
const statusIcons = {
unchecked: "🔘",
checking: "🔄",
healthy: "✅",
error: "❌",
};
const statusText = {
unchecked: "Not checked",
checking: "Checking...",
healthy: "Connected",
error: "Not connected",
};
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isOpen]);
return (
<div className="relative" ref={menuRef}>
{/* Settings Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Settings"
title="Settings"
>
<svg className="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Settings</h3>
{/* Admin Mode Toggle */}
<div className="mb-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">Admin Mode</span>
{adminMode && <span className="text-xs text-red-600 font-semibold">AKTIV</span>}
</div>
<button
onClick={() => onAdminModeChange(!adminMode)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
adminMode ? 'bg-red-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
adminMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{adminMode && (
<p className="mt-2 text-xs text-red-600 px-3 whitespace-nowrap">
⚠️ Allows deleting sessions
</p>
)}
</div>
{/* Galactic Admin Toggle - Only visible to actual galactic admins */}
{user?.isGalacticAdmin && (
<div className="mb-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-purple-50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">Galactic Admin</span>
{galacticAdminMode && <span className="text-xs text-purple-700 font-semibold">ACTIVE</span>}
</div>
<button
onClick={() => onGalacticAdminModeChange(!galacticAdminMode)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
galacticAdminMode ? 'bg-purple-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
galacticAdminMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{galacticAdminMode && (
<p className="mt-2 text-xs text-purple-700 px-3">
Shows ALL sessions across ALL tenants
</p>
)}
</div>
)}
{/* API Status Section - Clickable */}
<div className="border-t border-gray-200 pt-3">
<button
onClick={onCheckHealth}
disabled={apiStatus === "checking"}
className="w-full p-3 text-left rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={apiStatus === "unchecked" ? "Click to check API status" : undefined}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">API Status</span>
<span className={`text-sm font-medium ${statusColors[apiStatus]}`}>
{statusIcons[apiStatus]} {statusText[apiStatus]}
</span>
</div>
</button>
</div>
{/* Configuration Section */}
<div className="border-t border-gray-200 pt-3 mt-3">
<a
href="/settings"
className="block w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Configuration</span>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</a>
</div>
{/* Usage Metrics Section - Always visible for tenant */}
<div className="border-t border-gray-200 pt-3">
<a
href="/usage-metrics"
className="block w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Usage Metrics</span>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</a>
</div>
{/* Platform Usage Metrics Section - Galactic Admin Only */}
{galacticAdminMode && (
<div className="border-t border-gray-200 pt-3">
<a
href="/platform-usage-metrics"
className="block w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Platform Usage Metrics</span>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</a>
</div>
)}
{/* Audit Log Section */}
<div className="border-t border-gray-200 pt-3">
<button
onClick={() => {
setShowAuditLog(true);
fetchAuditLogs();
}}
className="w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Audit Log</span>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</button>
</div>
</div>
</div>
)}
{/* Audit Log Modal */}
{showAuditLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60]" onClick={() => setShowAuditLog(false)}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Audit Log</h2>
<button
onClick={() => setShowAuditLog(false)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
{loadingLogs ? (
<div className="text-center py-8 text-gray-500">Lade Audit Logs...</div>
) : auditLogs.length === 0 ? (
<div className="text-center py-8 text-gray-500">Keine Audit Logs vorhanden</div>
) : (
<div className="space-y-4">
{auditLogs.map((log) => (
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 text-xs font-semibold rounded ${
log.action === 'DELETE' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'
}`}>
{log.action}
</span>
<span className="text-sm font-medium text-gray-900">{log.entityType}</span>
<span className="text-sm text-gray-500 font-mono">{log.entityId}</span>
</div>
<div className="mt-2 text-sm text-gray-600">
von <span className="font-medium">{log.performedBy}</span>
</div>
{log.details && log.details !== '{}' && (
<div className="mt-2 text-xs text-gray-500 bg-gray-50 p-2 rounded">
{log.details}
</div>
)}
</div>
<div className="text-xs text-gray-500 text-right">
{new Date(log.timestamp).toLocaleString('de-DE')}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
export function StatsCard({
title,
value,
description,
color,
}: {
title: string;
value: string;
description: string;
color: "blue" | "green" | "purple" | "red";
}) {
const colorClasses = {
blue: "bg-blue-500",
green: "bg-green-500",
purple: "bg-purple-500",
red: "bg-red-500",
};
return (
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`${colorClasses[color]} rounded-md p-3`}>
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{value}</div>
</dd>
</dl>
</div>
</div>
<div className="mt-2">
<div className="text-sm text-gray-500">{description}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState, useRef } from "react";
export function UserMenu({
username,
email,
onLogout,
}: {
username: string;
email: string;
onLogout: () => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const getInitials = (name: string) => {
return name
.split(" ")
.map(n => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isOpen]);
return (
<div className="relative" ref={menuRef}>
{/* User Avatar Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 p-1 rounded-full hover:bg-gray-100 transition-colors"
aria-label="User menu"
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
{getInitials(username)}
</div>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="p-4">
{/* User Info */}
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white text-lg font-medium">
{getInitials(username)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-900 truncate">
{username}
</div>
<div className="text-xs text-gray-500 truncate">
{email}
</div>
<button className="text-xs text-blue-600 hover:text-blue-800 mt-1">
View account
</button>
</div>
</div>
{/* Sign out */}
<div className="pt-3">
<button
onClick={onLogout}
className="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
Sign out
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
export function WelcomeMessage() {
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Top accent bar */}
<div className="h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500" />
<div className="p-6 sm:p-8">
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="shrink-0 w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 leading-tight">Welcome to Autopilot Monitor</h2>
<p className="mt-1 text-sm text-gray-500">Your tenant is connected — deploy the agent to start monitoring enrollments.</p>
</div>
</div>
{/* Steps */}
<ol className="space-y-3 mb-7">
<li className="flex items-start gap-3">
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
<p className="text-sm text-gray-700">
Follow the{" "}
<a href="/docs#setup" className="text-blue-600 hover:underline font-medium">setup guide</a>
{" "}to configure your tenant and grant the required permissions.
</p>
</li>
<li className="flex items-start gap-3">
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
<p className="text-sm text-gray-700">
Deploy the{" "}
<a href="/docs#agent-setup" className="text-blue-600 hover:underline font-medium">Intune bootstrapper</a>
{" "}to your Autopilot device groups — the agent installs automatically on first boot.
</p>
</li>
<li className="flex items-start gap-3">
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
<p className="text-sm text-gray-700">Enrollment sessions will appear here in real time as devices go through the enrollment process.</p>
</li>
</ol>
{/* Quick links */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="/docs"
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-blue-300 hover:bg-blue-50 transition-colors"
>
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-blue-200 flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</span>
<div>
<p className="text-sm font-medium text-gray-900">Documentation</p>
<p className="text-xs text-gray-500">Setup and configuration guides</p>
</div>
<svg className="w-4 h-4 text-gray-300 group-hover:text-blue-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
<a
href="https://github.com/okieselbach/Autopilot-Monitor"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-gray-400 hover:bg-gray-100 transition-colors"
>
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-gray-300 flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-gray-700" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</span>
<div>
<p className="text-sm font-medium text-gray-900">GitHub Repository</p>
<p className="text-xs text-gray-500">Source code and issue tracking</p>
</div>
<svg className="w-4 h-4 text-gray-300 group-hover:text-gray-500 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
export interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
isPreProvisioned?: boolean;
isHybridJoin?: boolean;
}
@@ -0,0 +1,60 @@
"use client";
export function ConfidenceBadge({
score,
compact = false,
}: {
score: number;
compact?: boolean;
}) {
const color =
score >= 80
? "text-red-600 bg-red-50 border-red-200"
: score >= 60
? "text-orange-600 bg-orange-50 border-orange-200"
: score >= 40
? "text-yellow-600 bg-yellow-50 border-yellow-200"
: "text-blue-600 bg-blue-50 border-blue-200";
if (compact) {
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border ${color}`}
>
{score}%
</span>
);
}
return (
<div className={`inline-flex items-center space-x-1 px-3 py-1 rounded-lg border ${color}`}>
<span className="text-sm font-bold">{score}%</span>
<span className="text-xs opacity-75">CONFIDENCE</span>
</div>
);
}
export function SeverityBadge({
severity,
compact = false,
}: {
severity: string;
compact?: boolean;
}) {
const colors: Record<string, string> = {
critical: "bg-red-100 text-red-800 border-red-200",
high: "bg-orange-100 text-orange-800 border-orange-200",
warning: "bg-yellow-100 text-yellow-800 border-yellow-200",
info: "bg-blue-100 text-blue-800 border-blue-200",
};
const c = colors[severity] || colors.info;
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${c}`}
>
{compact ? severity.charAt(0).toUpperCase() : severity.toUpperCase()}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More