diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index e6a8f109..822f0664 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -38,7 +38,10 @@ param( [string]$ApiBaseUrl = "http://localhost:7071", [Parameter(Mandatory = $false)] - [string]$AgentDownloadUrl = "https://yourcdn.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip" + [string]$AgentDownloadUrl = "https://yourcdn.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip", + + [Parameter(Mandatory = $false)] + [int]$MaxOsAgeHours = 5 ) # Configuration - Everything in ProgramData for easy cleanup @@ -71,6 +74,49 @@ try { Write-Log "===== Autopilot Monitor Bootstrap Started =====" Write-Log "API Base URL: $ApiBaseUrl" + # ── Pre-flight: Skip installation on already-enrolled devices ── + + # Check 1: OS install date must be within threshold (fresh device) + $osInstallDate = (Get-CimInstance Win32_OperatingSystem).InstallDate + $osAge = (Get-Date) - $osInstallDate + Write-Log "OS install date: $osInstallDate (age: $([math]::Round($osAge.TotalHours, 1))h)" + + if ($osAge.TotalHours -gt $MaxOsAgeHours) { + Write-Log "SKIP: OS was installed $([math]::Round($osAge.TotalHours, 1))h ago (threshold: ${MaxOsAgeHours}h). Device is not freshly provisioned." + exit 0 + } + + # Check 2: MDM enrollment must not be completed yet + $enrollmentPath = "HKLM:\SOFTWARE\Microsoft\Enrollments" + $mdmEnrolled = $false + + if (Test-Path $enrollmentPath) { + $enrollmentEntries = Get-ChildItem -Path $enrollmentPath -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -match '^[0-9A-Fa-f\-]{36}$' } + + foreach ($entry in $enrollmentEntries) { + $providerID = (Get-ItemProperty -Path $entry.PSPath -Name "ProviderID" -ErrorAction SilentlyContinue).ProviderID + if ($providerID) { + $mdmEnrolled = $true + Write-Log "Found MDM enrollment: ProviderID=$providerID (EnrollmentID=$($entry.PSChildName))" + break + } + } + } + + if ($mdmEnrolled) { + # Double-check: Is enrollment status tracking showing completed? + $espPath = "HKLM:\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup" + $hasCompleted = (Get-ItemProperty -Path $espPath -Name "HasProvisioningCompleted" -ErrorAction SilentlyContinue).HasProvisioningCompleted + + if ($hasCompleted -eq 1) { + Write-Log "SKIP: Autopilot enrollment already completed (HasProvisioningCompleted=1). Device is already enrolled." + exit 0 + } + } + + Write-Log "Pre-flight checks passed - device is freshly provisioned and enrollment in progress" + # Create agent configuration (JSON - NO REGISTRY!) Write-Log "Creating agent configuration..." $agentConfig = @{ diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs index 04e0c728..cd0ed901 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.Core.Configuration /// /// Whether to use client certificate authentication /// - public bool UseClientCertAuth { get; set; } = false; + public bool UseClientCertAuth { get; set; } = true; /// /// Client certificate thumbprint (if using cert auth) @@ -87,6 +87,16 @@ namespace AutopilotMonitor.Agent.Core.Configuration /// public string ScheduledTaskName { get; set; } = "AutopilotMonitor-Agent"; + /// + /// Whether to reboot the device after enrollment completes + /// + public bool RebootOnComplete { get; set; } = false; + + /// + /// Whether to enable geo-location detection (queries external IP services) + /// + public bool EnableGeoLocation { get; set; } = false; + /// /// Validates the configuration /// diff --git a/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/HelloDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/HelloDetector.cs new file mode 100644 index 00000000..6349a9b3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/HelloDetector.cs @@ -0,0 +1,299 @@ +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.EventCollection +{ + /// + /// Detects and tracks Windows Hello for Business provisioning during Autopilot enrollment. + /// Monitors the "Microsoft-Windows-User Device Registration/Admin" event log channel + /// and checks registry for WHfB policy configuration. + /// + /// Key Event IDs: + /// 358 - Prerequisites passed, provisioning will be launched + /// 360 - Prerequisites failed, provisioning will NOT be launched + /// 362 - Provisioning blocked + /// 300 - NGC key registered successfully (Hello provisioned) + /// 301 - NGC key registration failed + /// + public class HelloDetector : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private System.Diagnostics.Eventing.Reader.EventLogWatcher _watcher; + + private const string EventLogChannel = "Microsoft-Windows-User Device Registration/Admin"; + + // WHfB policy registry paths + private const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork"; + private const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork"; + + // Tracked event IDs + private const int EventId_NgcKeyRegistered = 300; + private const int EventId_NgcKeyRegistrationFailed = 301; + private const int EventId_ProvisioningWillLaunch = 358; + private const int EventId_ProvisioningWillNotLaunch = 360; + private const int EventId_ProvisioningBlocked = 362; + private const int EventId_PinStatus = 376; + + private static readonly HashSet TrackedEventIds = new HashSet + { + EventId_NgcKeyRegistered, + EventId_NgcKeyRegistrationFailed, + EventId_ProvisioningWillLaunch, + EventId_ProvisioningWillNotLaunch, + EventId_ProvisioningBlocked, + EventId_PinStatus + }; + + public HelloDetector(string sessionId, string tenantId, Action onEventCollected, AgentLogger logger) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Start() + { + _logger.Info("Starting Hello detector"); + + // Check if WHfB policy is configured + CheckHelloPolicy(); + + // Subscribe to User Device Registration event log + StartEventLogWatcher(); + } + + public void Stop() + { + _logger.Info("Stopping Hello detector"); + + if (_watcher != null) + { + try + { + _watcher.Enabled = false; + _watcher.EventRecordWritten -= OnEventRecordWritten; + _watcher.Dispose(); + _watcher = null; + } + catch (Exception ex) + { + _logger.Error("Error stopping Hello detector watcher", ex); + } + } + } + + private void CheckHelloPolicy() + { + try + { + var (isEnabled, source) = DetectHelloPolicy(); + + if (isEnabled.HasValue) + { + var status = isEnabled.Value ? "enabled" : "disabled"; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_policy_detected", + Severity = EventSeverity.Info, + Source = "HelloDetector", + Phase = EnrollmentPhase.EspDeviceSetup, + Message = $"Windows Hello for Business policy detected: {status} (via {source})", + Data = new Dictionary + { + { "helloEnabled", isEnabled.Value }, + { "policySource", source } + } + }); + + _logger.Info($"WHfB policy: {status} (source: {source})"); + } + else + { + _logger.Info("No WHfB policy found in registry - Hello may use tenant default"); + } + } + 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) + try + { + using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false)) + { + if (baseCspKey != null) + { + foreach (var tenantSubKey in baseCspKey.GetSubKeyNames()) + { + using (var policiesKey = baseCspKey.OpenSubKey($@"{tenantSubKey}\Device\Policies", false)) + { + if (policiesKey != null) + { + var value = policiesKey.GetValue("UsePassportForWork"); + if (value != null) + { + return (Convert.ToInt32(value) == 1, "CSP/Intune"); + } + } + } + } + } + } + } + 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[TimeCreated[timediff(@SystemTime) <= 1000]]]" + ); + + _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; + + var description = record.FormatDescription() ?? $"Event ID {eventId}"; + + string eventType; + EventSeverity severity; + string message; + + switch (eventId) + { + case EventId_ProvisioningWillLaunch: // 358 + eventType = "hello_provisioning_started"; + severity = EventSeverity.Info; + message = "Windows Hello for Business provisioning started - prerequisites passed"; + break; + + case EventId_NgcKeyRegistered: // 300 + eventType = "hello_provisioning_completed"; + severity = EventSeverity.Info; + message = "Windows Hello for Business provisioned successfully - NGC key registered"; + break; + + case EventId_NgcKeyRegistrationFailed: // 301 + eventType = "hello_provisioning_failed"; + severity = EventSeverity.Error; + message = "Windows Hello for Business provisioning failed - NGC key registration error"; + break; + + case EventId_ProvisioningWillNotLaunch: // 360 + eventType = "hello_provisioning_skipped"; + severity = EventSeverity.Warning; + message = "Windows Hello for Business provisioning skipped - prerequisites not met"; + break; + + case EventId_ProvisioningBlocked: // 362 + eventType = "hello_provisioning_blocked"; + severity = EventSeverity.Warning; + message = "Windows Hello for Business provisioning blocked"; + break; + + case EventId_PinStatus: // 376 + eventType = "hello_pin_status"; + severity = EventSeverity.Info; + message = "Windows Hello PIN status update"; + break; + + default: + return; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = record.TimeCreated ?? DateTime.UtcNow, + EventType = eventType, + Severity = severity, + Source = "HelloDetector", + Phase = EnrollmentPhase.EspDeviceSetup, + Message = message, + Data = new Dictionary + { + { "windowsEventId", eventId }, + { "providerName", record.ProviderName ?? "" }, + { "description", description } + } + }); + + _logger.Info($"Hello event detected: {eventType} (EventID {eventId})"); + } + catch (Exception ex) + { + _logger.Error("Error processing Hello event record", ex); + } + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs index f990231d..69553477 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs @@ -7,6 +7,7 @@ using AutopilotMonitor.Agent.Core.Api; using AutopilotMonitor.Agent.Core.Configuration; using AutopilotMonitor.Agent.Core.EventCollection; using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Network; using AutopilotMonitor.Agent.Core.Storage; using AutopilotMonitor.Shared.Models; @@ -31,6 +32,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring private EventLogWatcher _eventLogWatcher; private RegistryMonitor _registryMonitor; private PhaseDetector _phaseDetector; + private HelloDetector _helloDetector; private AutopilotSimulator _simulator; public MonitoringService(AgentConfiguration configuration, AgentLogger logger) @@ -96,12 +98,44 @@ namespace AutopilotMonitor.Agent.Core.Monitoring Message = "Autopilot Monitor Agent started" }); + // Collect and emit device geo-location (if enabled) + if (_configuration.EnableGeoLocation) + { + EmitGeoLocationEvent(); + } + // Start event collectors StartEventCollectors(); _logger.Info("Monitoring service started"); } + private void EmitGeoLocationEvent() + { + try + { + var location = GeoLocationService.GetLocationAsync(_logger).Result; + if (location != null) + { + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "device_location", + Severity = EventSeverity.Info, + Source = "Network", + Phase = EnrollmentPhase.PreFlight, + Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})", + Data = location.ToDictionary() + }); + } + } + catch (Exception ex) + { + _logger.Warning($"Failed to collect geo-location: {ex.Message}"); + } + } + /// /// Starts all event collection components /// @@ -138,6 +172,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring ); _phaseDetector.Start(); + // Start Hello detector (WHfB provisioning tracking) + _helloDetector = new HelloDetector( + _configuration.SessionId, + _configuration.TenantId, + EmitEvent, + _logger + ); + _helloDetector.Start(); + // Start Autopilot Simulator if enabled if (_configuration.EnableSimulator) { @@ -238,6 +281,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring { try { + var manufacturer = GetManufacturer(); + if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9") + // in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model + // which only contains the internal type number (e.g. "20Y3S0FP00") + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) + { + foreach (var obj in searcher.Get()) + { + return obj["Version"]?.ToString() ?? "Unknown"; + } + } + } + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem")) { foreach (var obj in searcher.Get()) @@ -312,6 +370,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring _phaseDetector?.Stop(); _phaseDetector?.Dispose(); + _helloDetector?.Stop(); + _helloDetector?.Dispose(); + _simulator?.Stop(); _simulator?.Dispose(); @@ -468,6 +529,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring { ExecuteCleanup(); } + else if (_configuration.RebootOnComplete) + { + _logger.Info("Reboot on complete enabled - initiating reboot"); + var psi = new ProcessStartInfo + { + FileName = "shutdown.exe", + Arguments = "/r /t 10 /c \"Autopilot enrollment completed - rebooting\"", + UseShellExecute = false, + CreateNoWindow = true + }; + Process.Start(psi); + } _logger.Info("Self-destruct sequence initiated. Agent will now exit."); @@ -488,7 +561,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring { try { - _logger.Info("Executing FULL SELF-DESTRUCT (Scheduled Task + Files)"); + _logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); var currentProcessId = Process.GetCurrentProcess().Id; var agentBasePath = Path.GetFullPath(Path.Combine( @@ -522,6 +595,11 @@ try {{ }} Write-Host 'AutopilotMonitor Agent self-destruct complete' +{(_configuration.RebootOnComplete ? @" +# Reboot the device +Write-Host 'Initiating reboot...' +Restart-Computer -Force +" : "")} "; // Write cleanup script to temp location (outside of agent folder) @@ -554,7 +632,7 @@ Write-Host 'AutopilotMonitor Agent self-destruct complete' { try { - _logger.Info("Executing CLEANUP (Files only, keeping Scheduled Task)"); + _logger.Info($"Executing CLEANUP (Files only, keeping Scheduled Task{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); var currentProcessId = Process.GetCurrentProcess().Id; var agentBasePath = Path.GetFullPath(Path.Combine( @@ -578,6 +656,11 @@ try {{ }} Write-Host 'AutopilotMonitor Agent cleanup complete' +{(_configuration.RebootOnComplete ? @" +# Reboot the device +Write-Host 'Initiating reboot...' +Restart-Computer -Force +" : "")} "; // Write cleanup script to temp location @@ -612,6 +695,7 @@ Write-Host 'AutopilotMonitor Agent cleanup complete' _eventLogWatcher?.Dispose(); _registryMonitor?.Dispose(); _phaseDetector?.Dispose(); + _helloDetector?.Dispose(); _simulator?.Dispose(); } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Network/GeoLocationService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Network/GeoLocationService.cs new file mode 100644 index 00000000..bda51f86 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Network/GeoLocationService.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.Core.Logging; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Agent.Core.Network +{ + public class GeoLocationResult + { + public string Country { get; set; } + public string Region { get; set; } + public string City { get; set; } + public string Loc { get; set; } + public string Timezone { get; set; } + public string Source { get; set; } + + public Dictionary ToDictionary() + { + return new Dictionary + { + { "country", Country }, + { "region", Region }, + { "city", City }, + { "loc", Loc }, + { "timezone", Timezone }, + { "source", Source } + }; + } + } + + public static class GeoLocationService + { + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); + + public static async Task GetLocationAsync(AgentLogger logger) + { + // Try ipinfo.io first + var result = await TryIpInfo(logger); + if (result != null) return result; + + // Fallback to ifconfig.co + result = await TryIfConfigCo(logger); + if (result != null) return result; + + logger?.Warning("GeoLocation: All providers failed, skipping location event"); + return null; + } + + private static async Task TryIpInfo(AgentLogger logger) + { + try + { + logger?.Info("GeoLocation: Querying ipinfo.io..."); + + using (var client = new HttpClient { Timeout = RequestTimeout }) + { + client.DefaultRequestHeaders.Add("Accept", "application/json"); + var response = await client.GetStringAsync("https://ipinfo.io/json"); + var json = JObject.Parse(response); + + var result = new GeoLocationResult + { + Country = json.Value("country"), + Region = json.Value("region"), + City = json.Value("city"), + Loc = json.Value("loc"), + Timezone = json.Value("timezone"), + Source = "ipinfo" + }; + + logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}"); + return result; + } + } + catch (Exception ex) + { + logger?.Warning($"GeoLocation: ipinfo.io failed: {ex.Message}"); + return null; + } + } + + private static async Task TryIfConfigCo(AgentLogger logger) + { + try + { + logger?.Info("GeoLocation: Falling back to ifconfig.co..."); + + using (var client = new HttpClient { Timeout = RequestTimeout }) + { + client.DefaultRequestHeaders.Add("Accept", "application/json"); + var response = await client.GetStringAsync("https://ifconfig.co/json"); + var json = JObject.Parse(response); + + var latitude = json.Value("latitude") ?? ""; + var longitude = json.Value("longitude") ?? ""; + var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude) + ? $"{latitude},{longitude}" + : ""; + + var result = new GeoLocationResult + { + Country = json.Value("country_iso"), + Region = json.Value("region_name"), + City = json.Value("city"), + Loc = loc, + Timezone = json.Value("time_zone"), + Source = "ifconfig.co" + }; + + logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}"); + return result; + } + } + catch (Exception ex) + { + logger?.Warning($"GeoLocation: ifconfig.co failed: {ex.Message}"); + return null; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs index dc7b8d7b..169cbab4 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -39,6 +39,7 @@ namespace AutopilotMonitor.Agent logger.Info("Agent starting in console mode"); Console.WriteLine($"Session ID: {config.SessionId}"); + Console.WriteLine($"Tenant ID: {config.TenantId}"); Console.WriteLine($"API URL: {config.ApiBaseUrl}"); Console.WriteLine($"Log Directory: {logDir}"); @@ -48,6 +49,16 @@ namespace AutopilotMonitor.Agent Console.WriteLine($"Simulate Failure: {(config.SimulateFailure ? "YES" : "NO")}"); } + if (config.RebootOnComplete) + { + Console.WriteLine($"Reboot on Complete: ENABLED"); + } + + if (config.EnableGeoLocation) + { + Console.WriteLine($"GeoLocation Detection: ENABLED"); + } + if (config.UseClientCertAuth) { Console.WriteLine($"Client Certificate Auth: ENABLED"); @@ -88,11 +99,14 @@ namespace AutopilotMonitor.Agent // Parse command-line arguments var enableSimulator = args?.Contains("--simulator") ?? false; var simulateFailure = args?.Contains("--simulate-failure") ?? false; - var useAuth = args?.Contains("--use-auth") ?? false; + var noAuth = args?.Contains("--no-auth") ?? false; var noCleanup = args?.Contains("--no-cleanup") ?? false; + var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false; + var enableGeoLocation = args?.Contains("--enable-geolocation") ?? false; // Parse certificate thumbprint if provided string certThumbprint = null; + string tenantIdOverride = null; if (args != null) { var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint"); @@ -100,6 +114,12 @@ namespace AutopilotMonitor.Agent { certThumbprint = args[thumbprintIndex + 1]; } + + var tenantIdIndex = Array.IndexOf(args, "--tenant-id"); + if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length) + { + tenantIdOverride = args[tenantIdIndex + 1]; + } } // Defaults (fallback values if no config file exists) @@ -107,6 +127,9 @@ namespace AutopilotMonitor.Agent int uploadIntervalSeconds = 30; bool cleanupOnExit = true; bool selfDestructOnComplete = true; + bool rebootOnCompleteConfig = false; + bool enableGeoLocationConfig = false; + bool useClientCertAuthConfig = true; // Try to load configuration from JSON file (written by PowerShell bootstrap script) var configFilePath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\agent-config.json"); @@ -131,6 +154,15 @@ namespace AutopilotMonitor.Agent if (configDict.ContainsKey("selfDestructOnComplete") && configDict["selfDestructOnComplete"] != null) selfDestructOnComplete = Convert.ToBoolean(configDict["selfDestructOnComplete"]); + if (configDict.ContainsKey("rebootOnComplete") && configDict["rebootOnComplete"] != null) + rebootOnCompleteConfig = Convert.ToBoolean(configDict["rebootOnComplete"]); + + if (configDict.ContainsKey("enableGeoLocation") && configDict["enableGeoLocation"] != null) + enableGeoLocationConfig = Convert.ToBoolean(configDict["enableGeoLocation"]); + + if (configDict.ContainsKey("useClientCertAuth") && configDict["useClientCertAuth"] != null) + useClientCertAuthConfig = Convert.ToBoolean(configDict["useClientCertAuth"]); + Console.WriteLine($"Loaded configuration from {configFilePath}"); } } @@ -161,7 +193,7 @@ namespace AutopilotMonitor.Agent ApiBaseUrl = apiBaseUrl, SessionId = Guid.NewGuid().ToString(), //TenantId = "deadbeef-dead-beef-dead-beefdeadbeef", - TenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359", + TenantId = tenantIdOverride ?? "b54dc1af-5320-4f60-b5d4-821e0cf2a359", SpoolDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Spool"), LogDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"), UploadIntervalSeconds = uploadIntervalSeconds, @@ -169,10 +201,12 @@ namespace AutopilotMonitor.Agent EnableDebugLogging = true, EnableSimulator = enableSimulator, SimulateFailure = simulateFailure, - UseClientCertAuth = useAuth, + UseClientCertAuth = !noAuth && useClientCertAuthConfig, ClientCertThumbprint = certThumbprint, CleanupOnExit = cleanupOnExit, - SelfDestructOnComplete = selfDestructOnComplete + SelfDestructOnComplete = selfDestructOnComplete, + RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig, + EnableGeoLocation = enableGeoLocation || enableGeoLocationConfig }; } } diff --git a/src/Web/autopilot-monitor-web/app/icon.svg b/src/Web/autopilot-monitor-web/app/icon.svg new file mode 100644 index 00000000..a3524134 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 3915bbd7..d92c612b 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -33,6 +33,7 @@ interface Session { currentPhase: number; eventCount: number; durationSeconds: number; + failureReason?: string; } const phaseNames: Record = { @@ -356,7 +357,7 @@ export default function SessionDetailPage() { - } /> + } /> @@ -640,7 +641,7 @@ function SeverityBadge({ severity }: { severity: string }) { ); } -function StatusBadge({ status }: { status: string }) { +function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) { const statusConfig = { InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" }, Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" }, @@ -650,9 +651,19 @@ function StatusBadge({ status }: { status: string }) { const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown; + const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out"); + return ( - + {config.text} + {isTimeout && ( + + ⏱️ + + )} ); } diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index ceccd386..57ab30dc 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -314,19 +314,36 @@ export default function Navbar() { {/* User Dropdown */} {showUserMenu && ( -
-
-

{user?.displayName || 'User'}

-

{user?.upn}

- {user?.isGalacticAdmin && ( - - Galactic Admin - - )} +
+
+
+ {(() => { + if (user?.displayName) { + const names = user.displayName.split(' '); + if (names.length >= 2) { + return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase(); + } + return user.displayName.charAt(0).toUpperCase(); + } + return user?.upn?.charAt(0).toUpperCase() || 'U'; + })()} +
+
+

{user?.displayName || 'User'}

+

{user?.upn}

+ {user?.isGalacticAdmin && ( + + Galactic Admin + + )} +
-