mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
massive re-org and re-structuring
This commit is contained in:
+701
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-679
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+554
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+347
@@ -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();
|
||||
|
||||
+716
@@ -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("'", "'");
|
||||
}
|
||||
|
||||
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("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+641
@@ -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>
|
||||
}
|
||||
}
|
||||
+565
@@ -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
+589
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+467
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+298
@@ -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,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
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+303
@@ -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>() };
|
||||
}
|
||||
}
|
||||
}
|
||||
+539
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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 "+ Add Factor" 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 "+ Add Section" 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 "+ Add Link" 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;
|
||||
}
|
||||
+60
@@ -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
Reference in New Issue
Block a user