Agent re-architectured

- new IME log parser
- IME log replay option
- revised timeline and enrollment tracker
UI
- removed flight tracker
- fixed download tracker
- nice progress portal page
This commit is contained in:
Oliver Kieselbach
2026-02-10 02:37:31 +01:00
parent 7bb05ff4fe
commit 3e3cc904f7
50 changed files with 4061 additions and 3603 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ bld/
# Visual Studio cache/options
.vs/
.vscode/
*.rsuser
*.suo
*.user
+6
View File
@@ -0,0 +1,6 @@
{
"recommendations": [
"ms-azuretools.vscode-azurefunctions",
"ms-dotnettools.csharp"
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to .NET Functions",
"type": "coreclr",
"request": "attach",
"processId": "${command:azureFunctions.pickProcess}"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"azureFunctions.projectSubpath": "src\\Backend\\AutopilotMonitor.Functions",
"azureFunctions.deploySubpath": "src\\Backend\\AutopilotMonitor.Functions/bin/Release/net8.0/publish",
"azureFunctions.projectLanguage": "C#",
"azureFunctions.projectRuntime": "~4",
"debug.internalConsoleOptions": "neverOpen",
"azureFunctions.preDeployTask": "publish (functions)"
}
+81
View File
@@ -0,0 +1,81 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "clean (functions)",
"command": "dotnet",
"args": [
"clean",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "process",
"problemMatcher": "$msCompile",
"options": {
"cwd": "${workspaceFolder}/src\\Backend\\AutopilotMonitor.Functions"
}
},
{
"label": "build (functions)",
"command": "dotnet",
"args": [
"build",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "process",
"dependsOn": "clean (functions)",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": "$msCompile",
"options": {
"cwd": "${workspaceFolder}/src\\Backend\\AutopilotMonitor.Functions"
}
},
{
"label": "clean release (functions)",
"command": "dotnet",
"args": [
"clean",
"--configuration",
"Release",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "process",
"problemMatcher": "$msCompile",
"options": {
"cwd": "${workspaceFolder}/src\\Backend\\AutopilotMonitor.Functions"
}
},
{
"label": "publish (functions)",
"command": "dotnet",
"args": [
"publish",
"--configuration",
"Release",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "process",
"dependsOn": "clean release (functions)",
"problemMatcher": "$msCompile",
"options": {
"cwd": "${workspaceFolder}/src\\Backend\\AutopilotMonitor.Functions"
}
},
{
"type": "func",
"dependsOn": "build (functions)",
"options": {
"cwd": "${workspaceFolder}/src\\Backend\\AutopilotMonitor.Functions/bin/Debug/net8.0"
},
"command": "host start",
"isBackground": true,
"problemMatcher": "$func-dotnet-watch"
}
]
}
@@ -97,6 +97,26 @@ namespace AutopilotMonitor.Agent.Core.Configuration
/// </summary>
public bool EnableGeoLocation { get; set; } = true;
/// <summary>
/// Optional custom path to IME logs directory for testing.
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
/// </summary>
public string ImeLogPathOverride { get; set; }
/// <summary>
/// Path to directory with real IME log files for simulation replay.
/// When set (with EnableSimulator), the simulator replays these logs with time compression
/// instead of generating artificial events.
/// </summary>
public string SimulationLogDirectory { get; set; }
/// <summary>
/// Time compression factor for simulation mode.
/// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay).
/// Targeting 3-5 minute total enrollment replay.
/// </summary>
public double SimulationSpeedFactor { get; set; } = 50;
/// <summary>
/// Validates the configuration
/// </summary>
@@ -2,7 +2,7 @@ using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Api;
using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -175,9 +175,6 @@ namespace AutopilotMonitor.Agent.Core.Configuration
_logger.Info(" Collector settings:");
_logger.Info($" Performance: {(collectors.EnablePerformanceCollector ? "ON" : "OFF")} (interval: {collectors.PerformanceIntervalSeconds}s)");
_logger.Info($" Download Progress: {(collectors.EnableDownloadProgressCollector ? "ON" : "OFF")} (interval: {collectors.DownloadProgressIntervalSeconds}s)");
_logger.Info($" Cert Validation: {(collectors.EnableCertValidationCollector ? "ON" : "OFF")}");
_logger.Info($" ESP UI State: {(collectors.EnableEspUiStateCollector ? "ON" : "OFF")} (interval: {collectors.EspUiStateIntervalSeconds}s)");
}
private void CacheConfig(AgentConfigResponse config)
@@ -1,522 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Simulates a realistic Autopilot enrollment flow for testing and demo purposes
/// </summary>
public class AutopilotSimulator : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly bool _simulateFailure;
private readonly Random _random = new Random();
private CancellationTokenSource _cancellationTokenSource;
private CancellationTokenSource _perfCancellationTokenSource;
private Task _simulationTask;
private EnrollmentPhase _currentPhase = EnrollmentPhase.PreFlight;
private bool _disposed = false;
private double _simulatedDiskFreeGb;
private readonly double _simulatedDiskTotalGb;
public AutopilotSimulator(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
bool simulateFailure = false)
{
_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));
_simulateFailure = simulateFailure;
_simulatedDiskTotalGb = 237.0 + _random.Next(0, 20); // ~237-256 GB typical SSD
_simulatedDiskFreeGb = _simulatedDiskTotalGb - _random.Next(40, 60); // Start with 40-60 GB used
}
/// <summary>
/// Starts the Autopilot simulation
/// </summary>
public void Start()
{
_logger.Info("Starting Autopilot Simulator");
_cancellationTokenSource = new CancellationTokenSource();
_simulationTask = Task.Run(() => RunSimulation(_cancellationTokenSource.Token));
}
/// <summary>
/// Stops the simulation
/// </summary>
public void Stop()
{
if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested)
return;
_logger.Info("Stopping Autopilot Simulator");
try
{
_cancellationTokenSource?.Cancel();
_simulationTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
}
private async Task RunSimulation(CancellationToken cancellationToken)
{
try
{
_logger.Info("Starting simulated Autopilot enrollment flow");
// Start background performance snapshot emitter (with its own token so we can stop it on completion)
_perfCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var perfTask = EmitPerformanceSnapshotsAsync(_perfCancellationTokenSource.Token);
// Phase 1: Network
await SimulatePhase(EnrollmentPhase.Network, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.Network, new[]
{
("network_connected", "Network connection established", EventSeverity.Info),
("wifi_connected", "Connected to WiFi network", EventSeverity.Info),
("internet_check", "Internet connectivity verified", EventSeverity.Info)
}, cancellationToken);
// Phase 2: Identity
await SimulatePhase(EnrollmentPhase.Identity, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.Identity, new[]
{
("aad_join_start", "Starting Azure AD join", EventSeverity.Info),
("device_registration", "Registering device with Azure AD", EventSeverity.Info),
("certificate_request", "Requesting device certificate", EventSeverity.Info),
("aad_join_complete", "Azure AD join completed successfully", EventSeverity.Info)
}, cancellationToken);
// Phase 3: MDM Enrollment
await SimulatePhase(EnrollmentPhase.MdmEnrollment, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.MdmEnrollment, new[]
{
("mdm_discovery", "Discovering MDM endpoints", EventSeverity.Info),
("intune_enrollment_start", "Starting Intune enrollment", EventSeverity.Info),
("policy_sync", "Syncing initial policies", EventSeverity.Info),
("profile_download", "Downloaded Autopilot profile", EventSeverity.Info),
("mdm_enrollment_complete", "MDM enrollment completed", EventSeverity.Info)
}, cancellationToken);
// Phase 4: ESP Device Setup
await SimulatePhase(EnrollmentPhase.EspDeviceSetup, cancellationToken);
if (_simulateFailure && _random.Next(0, 100) < 30)
{
// Simulate a failure during device setup (30% chance if failure mode enabled)
await EmitPhaseEvents(EnrollmentPhase.EspDeviceSetup, new[]
{
("esp_device_start", "Starting ESP Device Setup", EventSeverity.Info),
("device_compliance_check", "Checking device compliance", EventSeverity.Info),
("security_baseline_apply", "Applying security baseline", EventSeverity.Warning),
("bitlocker_encryption", "BitLocker encryption failed", EventSeverity.Error),
}, cancellationToken);
await SimulatePhase(EnrollmentPhase.Failed, cancellationToken);
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_failed",
Severity = EventSeverity.Critical,
Source = "Simulator",
Phase = EnrollmentPhase.Failed,
Message = "Enrollment failed: BitLocker encryption error",
Data = new Dictionary<string, object>
{
{ "errorCode", "0x80070002" },
{ "failedPhase", "EspDeviceSetup" }
}
});
_logger.Warning("Simulated enrollment failure");
StopPerformanceSnapshots();
return;
}
await EmitPhaseEvents(EnrollmentPhase.EspDeviceSetup, new[]
{
("esp_device_start", "Starting ESP Device Setup", EventSeverity.Info),
("device_compliance_check", "Checking device compliance", EventSeverity.Info),
("security_baseline_apply", "Applying security baseline", EventSeverity.Info),
("bitlocker_encryption", "Enabling BitLocker encryption", EventSeverity.Info),
("device_config_apply", "Applying device configurations", EventSeverity.Info),
("esp_device_complete", "ESP Device Setup completed", EventSeverity.Info)
}, cancellationToken);
// Phase 5: App Installation
await SimulatePhase(EnrollmentPhase.AppInstallation, cancellationToken);
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "app_install_start",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = EnrollmentPhase.AppInstallation,
Message = "Starting application installation",
Data = new Dictionary<string, object> { { "simulated", true } }
});
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
// Simulate realistic download progress for multiple apps
await SimulateAppDownloads(cancellationToken);
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "app_install_complete",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = EnrollmentPhase.AppInstallation,
Message = "All required apps installed",
Data = new Dictionary<string, object> { { "simulated", true }, { "appsInstalled", 5 } }
});
// Phase 6: ESP User Setup
await SimulatePhase(EnrollmentPhase.EspUserSetup, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.EspUserSetup, new[]
{
("esp_user_start", "Starting ESP User Setup", EventSeverity.Info),
("user_policy_sync", "Syncing user policies", EventSeverity.Info),
("user_profile_setup", "Setting up user profile", EventSeverity.Info),
("user_certs_install", "Installing user certificates", EventSeverity.Info),
("esp_user_complete", "ESP User Setup completed", EventSeverity.Info)
}, cancellationToken);
// Phase 7: Complete
await SimulatePhase(EnrollmentPhase.Complete, cancellationToken);
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_complete",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = EnrollmentPhase.Complete,
Message = "Autopilot enrollment completed successfully",
Data = new Dictionary<string, object>
{
{ "totalDurationSeconds", 420 }, // ~7 minutes
{ "appsInstalled", 3 },
{ "policiesApplied", 15 }
}
});
_logger.Info("Simulated Autopilot enrollment completed successfully");
StopPerformanceSnapshots();
}
catch (OperationCanceledException)
{
_logger.Info("Autopilot simulation cancelled");
}
catch (Exception ex)
{
_logger.Error("Error in Autopilot simulation", ex);
}
}
/// <summary>
/// Emits periodic performance_snapshot events throughout the simulation.
/// Disk free GB decreases gradually as apps are installed.
/// </summary>
private async Task EmitPerformanceSnapshotsAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
// CPU: fluctuates between 5-95%, higher during app install phase
var baseCpu = _currentPhase == EnrollmentPhase.AppInstallation ? 40.0 : 15.0;
var cpuPercent = Math.Min(100, baseCpu + _random.Next(-10, 40));
// Memory: 8-16 GB total, usage increases over time
var memTotalMb = 16384.0; // 16 GB
var memUsedPercent = 35.0 + _random.Next(0, 25);
var memAvailMb = memTotalMb * (1.0 - memUsedPercent / 100.0);
// Disk queue: higher during installs
var diskQueue = _currentPhase == EnrollmentPhase.AppInstallation
? 1.0 + _random.NextDouble() * 6.0
: _random.NextDouble() * 2.0;
// Disk free: smooth gradual decline throughout enrollment
// Small consistent decrements to produce a clean downward slope
if (_currentPhase == EnrollmentPhase.AppInstallation)
{
_simulatedDiskFreeGb -= (0.4 + _random.NextDouble() * 0.2); // 0.4-0.6 GB per snapshot (main install)
}
else if (_currentPhase == EnrollmentPhase.EspDeviceSetup)
{
_simulatedDiskFreeGb -= (0.15 + _random.NextDouble() * 0.1); // 0.15-0.25 GB (policies/configs)
}
else if (_currentPhase >= EnrollmentPhase.Network && _currentPhase <= EnrollmentPhase.MdmEnrollment)
{
_simulatedDiskFreeGb -= (0.05 + _random.NextDouble() * 0.05); // 0.05-0.1 GB (small overhead)
}
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "performance_snapshot",
Severity = EventSeverity.Debug,
Source = "Simulator",
Phase = _currentPhase,
Message = $"CPU: {cpuPercent:F1}%, Memory: {memUsedPercent:F1}%, Disk Free: {_simulatedDiskFreeGb:F1} GB",
Data = new Dictionary<string, object>
{
{ "cpu_percent", Math.Round(cpuPercent, 1) },
{ "memory_available_mb", Math.Round(memAvailMb, 0) },
{ "memory_total_mb", memTotalMb },
{ "memory_used_percent", Math.Round(memUsedPercent, 1) },
{ "disk_queue_length", Math.Round(diskQueue, 1) },
{ "disk_free_gb", Math.Round(_simulatedDiskFreeGb, 1) },
{ "disk_total_gb", Math.Round(_simulatedDiskTotalGb, 1) },
{ "simulated", true }
}
});
await Task.Delay(TimeSpan.FromSeconds(_random.Next(8, 15)), cancellationToken);
}
}
catch (OperationCanceledException)
{
// Expected when simulation ends
}
}
/// <summary>
/// Simulates realistic download progress for multiple apps with staggered starts,
/// incremental progress updates, and proper completion events
/// </summary>
private async Task SimulateAppDownloads(CancellationToken cancellationToken)
{
var apps = new[]
{
new { Name = "Microsoft 365 Apps for Enterprise", SizeBytes = (long)(2.1 * 1024 * 1024 * 1024) },
new { Name = "Microsoft Teams", SizeBytes = (long)(180 * 1024 * 1024) },
new { Name = "Microsoft Edge", SizeBytes = (long)(160 * 1024 * 1024) },
new { Name = "Company Portal", SizeBytes = (long)(45 * 1024 * 1024) },
new { Name = "Global Protect VPN", SizeBytes = (long)(95 * 1024 * 1024) },
};
// Track progress per app
var progress = new long[apps.Length];
var completed = new bool[apps.Length];
var started = new bool[apps.Length];
// Stagger start: app 0 starts immediately, others start after delays
var startAtTick = new int[apps.Length];
startAtTick[0] = 0;
for (int i = 1; i < apps.Length; i++)
{
startAtTick[i] = _random.Next(2, 5) + startAtTick[i - 1];
}
int tick = 0;
while (!AllCompleted(completed))
{
cancellationToken.ThrowIfCancellationRequested();
for (int i = 0; i < apps.Length; i++)
{
if (completed[i]) continue;
if (tick < startAtTick[i]) continue;
if (!started[i])
{
started[i] = true;
// Emit start event
EmitDownloadProgressEvent(apps[i].Name, 0, apps[i].SizeBytes, 0, "downloading");
continue;
}
// Simulate download chunk: variable speed between 15-80 MB/s
var speedMbps = _random.Next(15, 80);
var chunkBytes = (long)(speedMbps * 1024 * 1024 * (2.0 + _random.NextDouble())); // ~2-3 seconds worth
progress[i] = Math.Min(progress[i] + chunkBytes, apps[i].SizeBytes);
var rateBps = speedMbps * 1024.0 * 1024.0;
if (progress[i] >= apps[i].SizeBytes)
{
// Completed
completed[i] = true;
EmitDownloadProgressEvent(apps[i].Name, apps[i].SizeBytes, apps[i].SizeBytes, 0, "completed");
// Also emit an install event
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = $"app_install_{apps[i].Name.ToLower().Replace(" ", "_")}",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = EnrollmentPhase.AppInstallation,
Message = $"Installed {apps[i].Name}",
Data = new Dictionary<string, object>
{
{ "appName", apps[i].Name },
{ "simulated", true }
}
});
}
else
{
EmitDownloadProgressEvent(apps[i].Name, progress[i], apps[i].SizeBytes, rateBps, "downloading");
}
}
tick++;
await Task.Delay(TimeSpan.FromSeconds(_random.Next(2, 4)), cancellationToken);
}
}
private static bool AllCompleted(bool[] completed)
{
for (int i = 0; i < completed.Length; i++)
if (!completed[i]) return false;
return true;
}
private void EmitDownloadProgressEvent(string appName, long bytesDownloaded, long bytesTotal, double rateBps, string status)
{
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "Simulator",
Phase = EnrollmentPhase.AppInstallation,
Message = status == "completed"
? $"Download complete: {appName}"
: $"Downloading {appName}: {(bytesTotal > 0 ? (bytesDownloaded * 100.0 / bytesTotal).ToString("F0") : "?")}%",
Data = new Dictionary<string, object>
{
{ "app_name", appName },
{ "bytes_downloaded", bytesDownloaded },
{ "bytes_total", bytesTotal },
{ "download_rate_bps", Math.Round(rateBps, 0) },
{ "status", status },
{ "simulated", true }
}
});
}
private async Task SimulatePhase(EnrollmentPhase phase, CancellationToken cancellationToken)
{
var previousPhase = _currentPhase;
_currentPhase = phase;
// Emit phase change event
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "phase_changed",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = phase,
Message = $"Enrollment phase changed: {previousPhase} -> {phase}",
Data = new Dictionary<string, object>
{
{ "previousPhase", previousPhase.ToString() },
{ "currentPhase", phase.ToString() },
{ "phaseNumber", (int)phase }
}
});
// Small delay between phases (2-5 seconds)
await Task.Delay(TimeSpan.FromSeconds(_random.Next(2, 6)), cancellationToken);
}
private async Task EmitPhaseEvents(
EnrollmentPhase phase,
(string eventType, string message, EventSeverity severity)[] events,
CancellationToken cancellationToken)
{
foreach (var (eventType, message, severity) in events)
{
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "Simulator",
Phase = phase,
Message = message,
Data = new Dictionary<string, object>
{
{ "simulated", true },
{ "phase", phase.ToString() }
}
});
// Random delay between events (1-3 seconds)
await Task.Delay(TimeSpan.FromSeconds(_random.Next(1, 4)), cancellationToken);
}
}
private void EmitEvent(EnrollmentEvent evt)
{
try
{
_onEventCollected(evt);
_logger.Debug($"Simulator event: {evt.EventType} - {evt.Message}");
}
catch (Exception ex)
{
_logger.Error($"Error emitting simulator event: {evt.EventType}", ex);
}
}
private void StopPerformanceSnapshots()
{
try
{
_perfCancellationTokenSource?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed
}
}
public void Dispose()
{
if (_disposed)
return;
Stop();
_perfCancellationTokenSource?.Dispose();
_cancellationTokenSource?.Dispose();
_disposed = true;
}
}
}
@@ -1,235 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Validates certificate chains for critical enrollment endpoints
/// Detects SSL inspection, untrusted CAs, and certificate issues
/// Optional collector - toggled on/off via remote config
/// </summary>
public class CertValidationCollector : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private bool _hasRun;
// Critical enrollment endpoints to validate
private static readonly EndpointCheck[] Endpoints = new[]
{
new EndpointCheck("enterpriseregistration.windows.net", 443, new[] { "Microsoft", "DigiCert" }),
new EndpointCheck("login.microsoftonline.com", 443, new[] { "Microsoft", "DigiCert" }),
new EndpointCheck("device.login.microsoftonline.com", 443, new[] { "Microsoft", "DigiCert" }),
new EndpointCheck("enrollment.manage.microsoft.com", 443, new[] { "Microsoft", "DigiCert", "Baltimore" }),
new EndpointCheck("portal.manage.microsoft.com", 443, new[] { "Microsoft", "DigiCert", "Baltimore" })
};
public CertValidationCollector(string sessionId, string tenantId, Action<EnrollmentEvent> 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));
}
/// <summary>
/// Starts the collector - runs validation once at startup
/// Call RunValidation() again for subsequent checks (e.g., on network phase completion)
/// </summary>
public void Start()
{
_logger.Info("Starting Certificate Validation collector");
ThreadPool.QueueUserWorkItem(_ => RunValidation());
}
public void Stop()
{
_logger.Info("Stopping Certificate Validation collector");
}
/// <summary>
/// Runs certificate validation for all endpoints
/// Can be called multiple times (e.g., at startup + after network phase)
/// </summary>
public void RunValidation()
{
_logger.Info("Running certificate validation checks...");
foreach (var endpoint in Endpoints)
{
try
{
ValidateEndpoint(endpoint);
}
catch (Exception ex)
{
_logger.Debug($"Cert validation for {endpoint.Hostname} failed: {ex.Message}");
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "cert_validation",
Severity = EventSeverity.Warning,
Source = "CertValidationCollector",
Message = $"Certificate validation failed for {endpoint.Hostname}: {ex.Message}",
Data = new Dictionary<string, object>
{
{ "endpoint", endpoint.Hostname },
{ "port", endpoint.Port },
{ "chain_valid", false },
{ "error", ex.Message },
{ "ssl_inspection_detected", false }
}
});
}
}
_hasRun = true;
_logger.Info("Certificate validation checks complete");
}
private void ValidateEndpoint(EndpointCheck endpoint)
{
X509Certificate2 serverCert = null;
X509Chain chain = null;
var chainDetails = new List<Dictionary<string, string>>();
bool chainValid = false;
bool sslInspectionDetected = false;
try
{
var request = (HttpWebRequest)WebRequest.Create($"https://{endpoint.Hostname}:{endpoint.Port}");
request.Timeout = 10000;
request.AllowAutoRedirect = false;
// Custom validation callback to capture certificate details
request.ServerCertificateValidationCallback = (sender, certificate, certChain, sslPolicyErrors) =>
{
if (certificate != null)
{
serverCert = new X509Certificate2(certificate);
}
chain = certChain;
chainValid = sslPolicyErrors == SslPolicyErrors.None;
// Capture chain details
if (certChain != null)
{
foreach (var element in certChain.ChainElements)
{
chainDetails.Add(new Dictionary<string, string>
{
{ "subject", element.Certificate.Subject },
{ "issuer", element.Certificate.Issuer },
{ "thumbprint", element.Certificate.Thumbprint },
{ "notBefore", element.Certificate.NotBefore.ToString("o") },
{ "notAfter", element.Certificate.NotAfter.ToString("o") }
});
}
}
return true; // Accept all certs for inspection purposes
};
using (var response = (HttpWebResponse)request.GetResponse())
{
// We just needed the certificate - response content doesn't matter
}
}
catch (WebException ex)
{
// Connection failures or HTTP errors are OK - we still got the certificate
if (serverCert == null)
{
throw new Exception($"Could not connect to {endpoint.Hostname}: {ex.Message}");
}
}
if (serverCert == null)
return;
// Check for SSL inspection by examining the issuer
var issuer = serverCert.Issuer;
sslInspectionDetected = !endpoint.ExpectedIssuers.Any(
expected => issuer.IndexOf(expected, StringComparison.OrdinalIgnoreCase) >= 0
);
var severity = EventSeverity.Info;
var message = $"Certificate valid for {endpoint.Hostname}";
if (sslInspectionDetected)
{
severity = EventSeverity.Warning;
message = $"SSL inspection detected for {endpoint.Hostname} (Issuer: {issuer})";
_logger.Warning(message);
}
else if (!chainValid)
{
severity = EventSeverity.Warning;
message = $"Certificate chain invalid for {endpoint.Hostname}";
_logger.Warning(message);
}
var data = new Dictionary<string, object>
{
{ "endpoint", endpoint.Hostname },
{ "port", endpoint.Port },
{ "chain_valid", chainValid },
{ "ssl_inspection_detected", sslInspectionDetected },
{ "subject", serverCert.Subject },
{ "issuer", serverCert.Issuer },
{ "thumbprint", serverCert.Thumbprint },
{ "not_before", serverCert.NotBefore.ToString("o") },
{ "not_after", serverCert.NotAfter.ToString("o") }
};
if (chainDetails.Count > 0)
{
data["chain_length"] = chainDetails.Count;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "cert_validation",
Severity = severity,
Source = "CertValidationCollector",
Message = message,
Data = data
});
}
public void Dispose()
{
Stop();
}
private class EndpointCheck
{
public string Hostname { get; }
public int Port { get; }
public string[] ExpectedIssuers { get; }
public EndpointCheck(string hostname, int port, string[] expectedIssuers)
{
Hostname = hostname;
Port = port;
ExpectedIssuers = expectedIssuers;
}
}
}
}
@@ -1,212 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Monitors IME content download directories to track app download progress
/// Optional collector - toggled on/off via remote config
/// </summary>
public class DownloadProgressCollector : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly int _intervalSeconds;
private Timer _pollTimer;
// Track file sizes for download rate calculation
private readonly Dictionary<string, long> _previousFileSizes = new Dictionary<string, long>();
private DateTime _lastCheck = DateTime.MinValue;
// IME content directories
private static readonly string[] MonitoredPaths = new[]
{
@"C:\Windows\IMECache",
@"C:\Program Files (x86)\Microsoft Intune Management Extension\Content"
};
public DownloadProgressCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, int intervalSeconds = 15)
{
_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));
_intervalSeconds = intervalSeconds;
}
public void Start()
{
_logger.Info($"Starting Download Progress collector (interval: {_intervalSeconds}s)");
_pollTimer = new Timer(
_ => CheckDownloads(),
null,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(_intervalSeconds)
);
}
public void Stop()
{
_logger.Info("Stopping Download Progress collector");
_pollTimer?.Dispose();
_pollTimer = null;
}
private void CheckDownloads()
{
try
{
var now = DateTime.UtcNow;
var elapsedSeconds = _lastCheck == DateTime.MinValue ? _intervalSeconds : (now - _lastCheck).TotalSeconds;
var currentFiles = new Dictionary<string, long>();
var hasActiveDownloads = false;
foreach (var basePath in MonitoredPaths)
{
if (!Directory.Exists(basePath))
continue;
try
{
var files = Directory.GetFiles(basePath, "*", SearchOption.AllDirectories);
foreach (var filePath in files)
{
try
{
var info = new FileInfo(filePath);
var size = info.Length;
currentFiles[filePath] = size;
// Check if this file is actively being downloaded (size changed)
long previousSize;
if (_previousFileSizes.TryGetValue(filePath, out previousSize))
{
if (size > previousSize)
{
hasActiveDownloads = true;
var bytesDownloaded = size - previousSize;
var downloadRateBps = elapsedSeconds > 0 ? bytesDownloaded / elapsedSeconds : 0;
var data = new Dictionary<string, object>
{
{ "file_name", Path.GetFileName(filePath) },
{ "directory", Path.GetDirectoryName(filePath) },
{ "bytes_downloaded", size },
{ "bytes_delta", bytesDownloaded },
{ "download_rate_bps", Math.Round(downloadRateBps, 0) },
{ "download_rate_mbps", Math.Round(downloadRateBps / (1024 * 1024), 2) }
};
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "DownloadProgressCollector",
Message = $"Download: {Path.GetFileName(filePath)} - {FormatBytes(size)} ({FormatBytes((long)downloadRateBps)}/s)",
Data = data
});
}
}
else if (size > 0)
{
// New file detected
hasActiveDownloads = true;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "DownloadProgressCollector",
Message = $"Download started: {Path.GetFileName(filePath)} ({FormatBytes(size)})",
Data = new Dictionary<string, object>
{
{ "file_name", Path.GetFileName(filePath) },
{ "directory", Path.GetDirectoryName(filePath) },
{ "bytes_downloaded", size },
{ "status", "started" }
}
});
}
}
catch (Exception)
{
// File may be locked or in use - skip
}
}
}
catch (Exception ex)
{
_logger.Debug($"Error scanning download directory {basePath}: {ex.Message}");
}
}
// Detect completed downloads (files that existed before but are no longer present)
foreach (var kvp in _previousFileSizes)
{
if (!currentFiles.ContainsKey(kvp.Key))
{
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "download_progress",
Severity = EventSeverity.Info,
Source = "DownloadProgressCollector",
Message = $"Download completed/removed: {Path.GetFileName(kvp.Key)} ({FormatBytes(kvp.Value)})",
Data = new Dictionary<string, object>
{
{ "file_name", Path.GetFileName(kvp.Key) },
{ "final_size", kvp.Value },
{ "status", "completed" }
}
});
}
}
// Update tracked files
_previousFileSizes.Clear();
foreach (var kvp in currentFiles)
{
_previousFileSizes[kvp.Key] = kvp.Value;
}
_lastCheck = now;
}
catch (Exception ex)
{
_logger.Warning($"Download progress check failed: {ex.Message}");
}
}
private string FormatBytes(long bytes)
{
if (bytes >= 1073741824)
return $"{bytes / 1073741824.0:F1} GB";
if (bytes >= 1048576)
return $"{bytes / 1048576.0:F1} MB";
if (bytes >= 1024)
return $"{bytes / 1024.0:F1} KB";
return $"{bytes} B";
}
public void Dispose()
{
Stop();
}
}
}
@@ -1,254 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Reads ESP (Enrollment Status Page) status from the registry
/// Tracks blocking apps, install progress, and status text
/// Optional collector - toggled on/off via remote config
/// </summary>
public class EspUiStateCollector : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly int _intervalSeconds;
private Timer _pollTimer;
// Track last known state to avoid duplicate events
private string _lastStateHash;
private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
public EspUiStateCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, int intervalSeconds = 30)
{
_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));
_intervalSeconds = intervalSeconds;
}
public void Start()
{
_logger.Info($"Starting ESP UI State collector (interval: {_intervalSeconds}s)");
_pollTimer = new Timer(
_ => CollectEspState(),
null,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(_intervalSeconds)
);
}
public void Stop()
{
_logger.Info("Stopping ESP UI State collector");
_pollTimer?.Dispose();
_pollTimer = null;
}
private void CollectEspState()
{
try
{
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath, false))
{
if (enrollmentsKey == null)
return;
var subKeyNames = enrollmentsKey.GetSubKeyNames();
foreach (var subKeyName in subKeyNames)
{
// Skip non-GUID keys
Guid guid;
if (!Guid.TryParse(subKeyName, out guid))
continue;
try
{
CollectEnrollmentEspState(enrollmentsKey, subKeyName);
}
catch (Exception ex)
{
_logger.Debug($"Failed to read ESP state for {subKeyName}: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
_logger.Debug($"ESP state collection failed: {ex.Message}");
}
}
private void CollectEnrollmentEspState(RegistryKey enrollmentsKey, string enrollmentGuid)
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid, false))
{
if (enrollmentKey == null)
return;
// Check for FirstSync tracking (ESP tracking policies)
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync", false))
{
if (firstSyncKey == null)
return;
var data = new Dictionary<string, object>();
// Read ESP status values
var deviceSetupStatus = ReadRegistryValue(firstSyncKey, "DeviceSetupStatus");
var accountSetupStatus = ReadRegistryValue(firstSyncKey, "AccountSetupStatus");
var espComplete = ReadRegistryValue(firstSyncKey, "IsComplete");
// Determine current ESP phase
string espPhase = "unknown";
if (accountSetupStatus != null)
espPhase = "account_setup";
else if (deviceSetupStatus != null)
espPhase = "device_setup";
data["phase"] = espPhase;
data["enrollment_guid"] = enrollmentGuid;
if (deviceSetupStatus != null)
data["device_setup_status"] = deviceSetupStatus;
if (accountSetupStatus != null)
data["account_setup_status"] = accountSetupStatus;
if (espComplete != null)
data["esp_complete"] = espComplete;
// Read blocking apps tracking
CollectBlockingApps(firstSyncKey, "Apps", data);
// Read tracking policies
CollectTrackingPolicies(firstSyncKey, data);
// Create state hash to avoid duplicate events
var stateHash = string.Join("|", data.OrderBy(k => k.Key).Select(k => $"{k.Key}={k.Value}"));
if (stateHash == _lastStateHash)
return; // No change
_lastStateHash = stateHash;
var blockingTotal = data.ContainsKey("blocking_apps_total") ? data["blocking_apps_total"] : 0;
var blockingCompleted = data.ContainsKey("blocking_apps_completed") ? data["blocking_apps_completed"] : 0;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "esp_ui_state",
Severity = EventSeverity.Debug,
Source = "EspUiStateCollector",
Message = $"ESP State: {espPhase} - Apps: {blockingCompleted}/{blockingTotal}",
Data = data
});
}
}
}
private void CollectBlockingApps(RegistryKey firstSyncKey, string subKeyName, Dictionary<string, object> data)
{
try
{
using (var appsKey = firstSyncKey.OpenSubKey(subKeyName, false))
{
if (appsKey == null)
return;
var appSubKeys = appsKey.GetSubKeyNames();
int total = appSubKeys.Length;
int completed = 0;
int failed = 0;
var appStatuses = new List<Dictionary<string, object>>();
foreach (var appId in appSubKeys)
{
using (var appKey = appsKey.OpenSubKey(appId, false))
{
if (appKey == null) continue;
var status = ReadRegistryValue(appKey, "Status");
var statusInt = 0;
if (status != null && int.TryParse(status, out statusInt))
{
if (statusInt == 1 || statusInt == 2) // Completed states
completed++;
else if (statusInt == 3) // Failed
failed++;
}
appStatuses.Add(new Dictionary<string, object>
{
{ "app_id", appId },
{ "status", status ?? "unknown" }
});
}
}
data["blocking_apps_total"] = total;
data["blocking_apps_completed"] = completed;
data["blocking_apps_failed"] = failed;
if (total > 0)
{
data["progress_percent"] = Math.Round(completed * 100.0 / total, 0);
}
}
}
catch (Exception ex)
{
_logger.Debug($"Failed to collect blocking apps: {ex.Message}");
}
}
private void CollectTrackingPolicies(RegistryKey firstSyncKey, Dictionary<string, object> data)
{
try
{
using (var policiesKey = firstSyncKey.OpenSubKey("TrackingPoliciesCreated", false))
{
if (policiesKey == null)
return;
var policyCount = policiesKey.GetSubKeyNames().Length;
data["tracking_policies_count"] = policyCount;
}
}
catch (Exception ex)
{
_logger.Debug($"Failed to collect tracking policies: {ex.Message}");
}
}
private string ReadRegistryValue(RegistryKey key, string valueName)
{
try
{
var value = key.GetValue(valueName);
return value?.ToString();
}
catch
{
return null;
}
}
public void Dispose()
{
Stop();
}
}
}
@@ -1,185 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Watches Windows Event Logs for Autopilot-related events
/// </summary>
public class EventLogWatcher : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly List<EventLogQuery> _queries = new List<EventLogQuery>();
private readonly List<System.Diagnostics.Eventing.Reader.EventLogWatcher> _watchers = new List<System.Diagnostics.Eventing.Reader.EventLogWatcher>();
// Autopilot Event Log channels
private static readonly string[] AutopilotLogChannels = new[]
{
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin",
"Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin",
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"
};
public EventLogWatcher(string sessionId, string tenantId, Action<EnrollmentEvent> 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));
}
/// <summary>
/// Starts watching event logs
/// </summary>
public void Start()
{
_logger.Info("Starting EventLog watchers");
foreach (var channel in AutopilotLogChannels)
{
try
{
// Create query for new events (from now onwards)
var query = new EventLogQuery(channel, PathType.LogName, "*[System[TimeCreated[timediff(@SystemTime) <= 1000]]]");
_queries.Add(query);
// Create watcher
var watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
watcher.EventRecordWritten += OnEventRecordWritten;
watcher.Enabled = true;
_watchers.Add(watcher);
_logger.Info($"Started watching: {channel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {channel} (This is normal if not on a real Autopilot device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start watcher for {channel}", ex);
}
}
if (_watchers.Count == 0)
{
_logger.Warning("No event log watchers started. Running in simulation mode.");
}
}
/// <summary>
/// Stops watching event logs
/// </summary>
public void Stop()
{
_logger.Info("Stopping EventLog watchers");
foreach (var watcher in _watchers)
{
try
{
watcher.Enabled = false;
watcher.EventRecordWritten -= OnEventRecordWritten;
watcher.Dispose();
}
catch (Exception ex)
{
_logger.Error("Error stopping watcher", ex);
}
}
_watchers.Clear();
_queries.Clear();
}
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
// Convert Windows Event to EnrollmentEvent
var evt = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = record.TimeCreated ?? DateTime.UtcNow,
EventType = $"eventlog_{record.Id}",
Severity = MapEventLevel(record.Level),
Source = record.ProviderName ?? "EventLog",
Phase = DetectPhaseFromEvent(record),
Message = record.FormatDescription() ?? $"Event ID {record.Id}",
Data = new Dictionary<string, object>
{
{ "eventId", record.Id },
{ "level", record.Level ?? 0 },
{ "logName", record.LogName },
{ "providerName", record.ProviderName }
}
};
_onEventCollected(evt);
}
catch (Exception ex)
{
_logger.Error("Error processing event record", ex);
}
}
private EventSeverity MapEventLevel(byte? level)
{
if (!level.HasValue)
return EventSeverity.Info;
switch (level.Value)
{
case 1: // Critical
return EventSeverity.Critical;
case 2: // Error
return EventSeverity.Error;
case 3: // Warning
return EventSeverity.Warning;
case 4: // Information
default:
return EventSeverity.Info;
}
}
private EnrollmentPhase DetectPhaseFromEvent(EventRecord record)
{
// Simple phase detection based on event content
var description = record.FormatDescription()?.ToLowerInvariant() ?? "";
if (description.Contains("device preparation") || description.Contains("preparing device"))
return EnrollmentPhase.MdmEnrollment;
if (description.Contains("device setup") || description.Contains("setting up device"))
return EnrollmentPhase.EspDeviceSetup;
if (description.Contains("account setup") || description.Contains("setting up account") || description.Contains("user setup"))
return EnrollmentPhase.EspUserSetup;
if (description.Contains("esp") || description.Contains("enrollment status"))
return EnrollmentPhase.EspDeviceSetup;
if (description.Contains("identity") || description.Contains("azure ad") || description.Contains("entra"))
return EnrollmentPhase.Identity;
if (description.Contains("app") || description.Contains("application"))
return EnrollmentPhase.AppInstallation;
if (description.Contains("complete") || description.Contains("finished"))
return EnrollmentPhase.Complete;
return EnrollmentPhase.PreFlight;
}
public void Dispose()
{
Stop();
}
}
}
@@ -1,238 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Detects and tracks Autopilot enrollment phases
/// </summary>
public class PhaseDetector : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private Timer _pollTimer;
private EnrollmentPhase _currentPhase = EnrollmentPhase.PreFlight;
// ESP (Enrollment Status Page) registry paths
private const string EspKeyPath = @"SOFTWARE\Microsoft\Enrollments";
private const string AutopilotKeyPath = @"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot";
public EnrollmentPhase CurrentPhase => _currentPhase;
public PhaseDetector(string sessionId, string tenantId, Action<EnrollmentEvent> 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));
}
/// <summary>
/// Starts phase detection
/// </summary>
public void Start()
{
_logger.Info("Starting Phase detector");
// Initial phase check
CheckPhase();
// Poll every 10 seconds
_pollTimer = new Timer(
_ => CheckPhase(),
null,
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(10)
);
}
/// <summary>
/// Stops phase detection
/// </summary>
public void Stop()
{
_logger.Info("Stopping Phase detector");
_pollTimer?.Dispose();
}
private void CheckPhase()
{
try
{
var detectedPhase = DetectCurrentPhase();
if (detectedPhase != _currentPhase)
{
var previousPhase = _currentPhase;
_currentPhase = detectedPhase;
// Emit phase change event
var evt = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "phase_changed",
Severity = EventSeverity.Info,
Source = "PhaseDetector",
Phase = _currentPhase,
Message = $"Enrollment phase changed: {previousPhase} -> {_currentPhase}",
Data = new Dictionary<string, object>
{
{ "previousPhase", previousPhase.ToString() },
{ "currentPhase", _currentPhase.ToString() },
{ "phaseNumber", (int)_currentPhase }
}
};
_onEventCollected(evt);
_logger.Info($"Phase changed: {previousPhase} -> {_currentPhase}");
// Check if enrollment completed or failed
if (_currentPhase == EnrollmentPhase.Complete)
{
_logger.Info("Enrollment completed successfully - emitting completion event");
var completionEvent = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_complete",
Severity = EventSeverity.Info,
Source = "PhaseDetector",
Phase = EnrollmentPhase.Complete,
Message = "Autopilot enrollment completed successfully"
};
_onEventCollected(completionEvent);
}
else if (_currentPhase == EnrollmentPhase.Failed)
{
_logger.Warning("Enrollment failed - emitting failure event");
var failureEvent = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_failed",
Severity = EventSeverity.Critical,
Source = "PhaseDetector",
Phase = EnrollmentPhase.Failed,
Message = "Autopilot enrollment failed"
};
_onEventCollected(failureEvent);
}
}
}
catch (Exception ex)
{
_logger.Error("Error detecting phase", ex);
}
}
private EnrollmentPhase DetectCurrentPhase()
{
// Try to detect phase from various indicators
// 1. Check ESP completion status first (highest priority)
try
{
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EspKeyPath, false))
{
if (enrollmentsKey != null)
{
var subKeyNames = enrollmentsKey.GetSubKeyNames();
foreach (var subKeyName in subKeyNames)
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(subKeyName, false))
{
if (enrollmentKey != null)
{
// Check for ESP tracking policies
using (var statusKey = enrollmentKey.OpenSubKey("Status", false))
{
if (statusKey != null)
{
// Check for completion indicators
var espComplete = statusKey.GetValue("EnrollmentStatusPageComplete");
var deviceSetupStatus = statusKey.GetValue("DeviceSetupStatus");
var accountSetupStatus = statusKey.GetValue("AccountSetupStatus");
// Check for failure
var hasError = statusKey.GetValue("HasError");
if (hasError != null && Convert.ToInt32(hasError) > 0)
{
return EnrollmentPhase.Failed;
}
// Check for completion (ESP finished successfully)
if (espComplete != null && Convert.ToInt32(espComplete) > 0)
{
return EnrollmentPhase.Complete;
}
// Check phase progression
if (accountSetupStatus != null)
{
var accountStatus = Convert.ToInt32(accountSetupStatus);
// 2 = Completed, check if this is final phase
if (accountStatus == 2)
{
return EnrollmentPhase.Complete;
}
return EnrollmentPhase.EspUserSetup;
}
if (deviceSetupStatus != null)
{
var deviceStatus = Convert.ToInt32(deviceSetupStatus);
// Check for failure during device setup
if (deviceStatus == 3) // 3 = Failed
{
return EnrollmentPhase.Failed;
}
return EnrollmentPhase.EspDeviceSetup;
}
}
}
}
}
}
}
}
}
catch { }
// 2. Check Autopilot registry for deployment profile
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotKeyPath, false))
{
if (key != null)
{
var profileName = key.GetValue("DeploymentProfileName") as string;
if (!string.IsNullOrEmpty(profileName))
{
// Profile downloaded - at least in MDM Enrollment
return EnrollmentPhase.MdmEnrollment;
}
}
}
}
catch { }
// 3. If we have an Autopilot session running, we're at least in PreFlight
return EnrollmentPhase.PreFlight;
}
public void Dispose()
{
Stop();
}
}
}
@@ -1,154 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Monitors Autopilot registry keys for status changes
/// </summary>
public class RegistryMonitor : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private Timer _pollTimer;
private readonly Dictionary<string, object> _lastValues = new Dictionary<string, object>();
// Autopilot registry paths
private const string AutopilotKeyPath = @"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot";
private const string AutopilotDiagnosticsPath = @"SOFTWARE\Microsoft\Provisioning\Diagnostics";
// Registry keys to monitor
private static readonly string[] MonitoredValues = new[]
{
"CloudAssignedTenantId",
"CloudAssignedOobeConfig",
"AutopilotServiceCorrelationId",
"DeploymentProfileName",
"IsAutopilotDisabled",
"CloudAssignedTenantDomain"
};
public RegistryMonitor(string sessionId, string tenantId, Action<EnrollmentEvent> 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));
}
/// <summary>
/// Starts monitoring registry keys
/// </summary>
public void Start()
{
_logger.Info("Starting Registry monitor");
// Initial read
CheckRegistryValues();
// Poll every 5 seconds
_pollTimer = new Timer(
_ => CheckRegistryValues(),
null,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5)
);
}
/// <summary>
/// Stops monitoring registry keys
/// </summary>
public void Stop()
{
_logger.Info("Stopping Registry monitor");
_pollTimer?.Dispose();
}
private void CheckRegistryValues()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotKeyPath, false))
{
if (key == null)
{
// Autopilot key doesn't exist - not an Autopilot device or not started yet
return;
}
foreach (var valueName in MonitoredValues)
{
try
{
var currentValue = key.GetValue(valueName);
if (currentValue != null)
{
var valueString = currentValue.ToString();
// Check if value changed
if (!_lastValues.ContainsKey(valueName) || !_lastValues[valueName].Equals(currentValue))
{
_lastValues[valueName] = currentValue;
// Emit event for registry change
var evt = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = $"registry_changed",
Severity = EventSeverity.Info,
Source = "RegistryMonitor",
Phase = DetectPhaseFromKey(valueName),
Message = $"Registry value changed: {valueName} = {valueString}",
Data = new Dictionary<string, object>
{
{ "registryKey", AutopilotKeyPath },
{ "valueName", valueName },
{ "value", valueString }
}
};
_onEventCollected(evt);
_logger.Debug($"Registry change detected: {valueName} = {valueString}");
}
}
}
catch (Exception ex)
{
_logger.Error($"Error reading registry value {valueName}", ex);
}
}
}
}
catch (Exception ex)
{
_logger.Error("Error checking registry values", ex);
}
}
private EnrollmentPhase DetectPhaseFromKey(string keyName)
{
// Map registry keys to phases
if (keyName.Contains("CloudAssigned") || keyName.Contains("Tenant"))
return EnrollmentPhase.Identity;
if (keyName.Contains("Profile") || keyName.Contains("Config"))
return EnrollmentPhase.MdmEnrollment;
return EnrollmentPhase.PreFlight;
}
public void Dispose()
{
Stop();
}
}
}
@@ -4,16 +4,19 @@ using System.Diagnostics;
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.EventCollection
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Executes gather rules received from the backend API
/// Supports registry, eventlog, wmi, file, and command (allowlisted) collector types
/// Supports registry, eventlog, wmi, file, command (allowlisted), and logparser collector types
/// </summary>
public class GatherRuleExecutor : IDisposable
{
@@ -21,10 +24,12 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly string _imeLogPathOverride;
private List<GatherRule> _activeRules = new List<GatherRule>();
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
private readonly LogFilePositionTracker _filePositionTracker = new LogFilePositionTracker();
/// <summary>
/// Strict command allowlist - only these commands can be executed via gather rules
@@ -59,12 +64,13 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
};
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger)
AgentLogger logger, string imeLogPathOverride = null)
{
_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));
_imeLogPathOverride = imeLogPathOverride;
}
/// <summary>
@@ -173,6 +179,10 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
result = ExecuteEventLogRule(rule);
break;
case "logparser":
ExecuteLogParserRule(rule);
return; // Return early - logparser emits events directly
default:
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
return;
@@ -538,6 +548,177 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
return data;
}
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);
}
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;
var match = pattern.Match(entry.Message);
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>
/// Checks if a command is on the allowlist
/// Uses exact match comparison for security
@@ -5,7 +5,7 @@ using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.EventCollection
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Detects and tracks Windows Hello for Business provisioning during Autopilot enrollment.
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
EventType = "hello_policy_detected",
Severity = EventSeverity.Info,
Source = "HelloDetector",
Phase = EnrollmentPhase.EspDeviceSetup,
Phase = EnrollmentPhase.Unknown,
Message = $"Windows Hello for Business policy detected: {status} (via {source})",
Data = new Dictionary<string, object>
{
@@ -273,7 +273,7 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
EventType = eventType,
Severity = severity,
Source = "HelloDetector",
Phase = EnrollmentPhase.EspDeviceSetup,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = new Dictionary<string, object>
{
@@ -7,7 +7,7 @@ using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Collects system performance metrics (CPU, memory, disk) on a configurable interval
@@ -139,6 +139,7 @@ namespace AutopilotMonitor.Agent.Core.EventCollection
EventType = "performance_snapshot",
Severity = EventSeverity.Debug,
Source = "PerformanceCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " +
$"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " +
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB",
@@ -5,7 +5,7 @@ using System.Linq;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.Core.Storage
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Manages offline event storage (spool) for resilient uploads
@@ -79,7 +79,7 @@ namespace AutopilotMonitor.Agent.Core.Storage
}
/// <summary>
/// Gets a batch of events from the spool (oldest first)
/// Gets a batch of events from the spool (oldest first, sorted by sequence)
/// </summary>
public List<EnrollmentEvent> GetBatch(int maxBatchSize)
{
@@ -87,8 +87,10 @@ namespace AutopilotMonitor.Agent.Core.Storage
{
var events = new List<EnrollmentEvent>();
var files = Directory.GetFiles(_spoolDirectory, "event_*.json")
.OrderBy(f => f)
.Select(f => new { Path = f, Sequence = ExtractSequenceFromFilename(f) })
.OrderBy(x => x.Sequence)
.Take(maxBatchSize)
.Select(x => x.Path)
.ToList();
foreach (var file in files)
@@ -112,6 +114,29 @@ namespace AutopilotMonitor.Agent.Core.Storage
}
}
/// <summary>
/// Extracts the sequence number from event filename
/// Filename format: event_{timestamp}_{sequence}.json
/// </summary>
private long ExtractSequenceFromFilename(string filePath)
{
try
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
var parts = fileName.Split('_');
if (parts.Length >= 3)
{
if (long.TryParse(parts[2], out var sequence))
return sequence;
}
}
catch
{
// Fallback to 0 if parsing fails
}
return 0;
}
/// <summary>
/// Removes events from the spool after successful upload
/// </summary>
@@ -1,17 +1,18 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
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.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Agent.Core.Monitoring.Simulation;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Main monitoring service that collects and uploads telemetry
@@ -29,17 +30,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
private EnrollmentPhase? _lastPhase = null;
// Core event collectors (always on)
private EventLogWatcher _eventLogWatcher;
private RegistryMonitor _registryMonitor;
private PhaseDetector _phaseDetector;
private HelloDetector _helloDetector;
private AutopilotSimulator _simulator;
// Optional collectors (toggled via remote config)
private PerformanceCollector _performanceCollector;
private DownloadProgressCollector _downloadProgressCollector;
private CertValidationCollector _certValidationCollector;
private EspUiStateCollector _espUiStateCollector;
// Smart enrollment tracking (replaces DownloadProgressCollector + EspUiStateCollector)
private EnrollmentTracker _enrollmentTracker;
// Remote config and gather rules
private RemoteConfigService _remoteConfigService;
@@ -96,7 +94,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
_spool.StartWatching();
_logger.Info("FileSystemWatcher started for efficient event upload");
// Emit initial event
// Emit agent_started event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
@@ -104,8 +102,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
EventType = "agent_started",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.PreFlight,
Message = "Autopilot Monitor Agent started"
Phase = EnrollmentPhase.Start,
Message = "Autopilot Monitor Agent started",
Data = new Dictionary<string, object>
{
{ "agentVersion", "1.0.0" }
}
});
// Collect and emit device geo-location (if enabled)
@@ -114,6 +116,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
EmitGeoLocationEvent();
}
// Note: Device info (network, DNS, proxy, Autopilot profile, AAD join, etc.)
// is collected by EnrollmentTracker when it starts
// Fetch remote config (collector toggles + gather rules) from backend
FetchRemoteConfig();
@@ -146,7 +151,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
EventType = "device_location",
Severity = EventSeverity.Info,
Source = "Network",
Phase = EnrollmentPhase.PreFlight,
Phase = EnrollmentPhase.Unknown,
Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})",
Data = location.ToDictionary()
});
@@ -167,33 +172,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
try
{
// Start EventLog watcher
_eventLogWatcher = new EventLogWatcher(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_eventLogWatcher.Start();
// Start Registry monitor
_registryMonitor = new RegistryMonitor(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_registryMonitor.Start();
// Start Phase detector
_phaseDetector = new PhaseDetector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_phaseDetector.Start();
// Start Hello detector (WHfB provisioning tracking)
_helloDetector = new HelloDetector(
_configuration.SessionId,
@@ -207,12 +185,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
if (_configuration.EnableSimulator)
{
_logger.Info("Simulator mode enabled - starting Autopilot simulator");
var imeLogPatterns = _remoteConfigService?.CurrentConfig?.ImeLogPatterns;
_simulator = new AutopilotSimulator(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
_configuration.SimulateFailure
_configuration.SimulateFailure,
_configuration.SimulationLogDirectory,
_configuration.SimulationSpeedFactor,
imeLogPatterns
);
_simulator.Start();
_logger.Info("Autopilot simulator started");
@@ -251,61 +233,35 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
var config = _remoteConfigService?.CurrentConfig;
var collectors = config?.Collectors;
if (collectors == null)
{
_logger.Info("No remote config available - optional collectors disabled");
return;
}
_logger.Info("Starting optional collectors based on remote config");
_logger.Info("Starting collectors based on remote config");
try
{
if (collectors.EnablePerformanceCollector)
// PerformanceCollector is always on (feeds UI chart)
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
_performanceCollector = new PerformanceCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
perfInterval
);
_performanceCollector.Start();
// EnrollmentTracker: smart enrollment tracking with IME log parsing
// (replaces DownloadProgressCollector + EspUiStateCollector)
if (!_configuration.EnableSimulator)
{
_performanceCollector = new PerformanceCollector(
var imeLogPatterns = config?.ImeLogPatterns;
_enrollmentTracker = new EnrollmentTracker(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
collectors.PerformanceIntervalSeconds
imeLogPatterns,
_configuration.ImeLogPathOverride
);
_performanceCollector.Start();
}
if (collectors.EnableDownloadProgressCollector)
{
_downloadProgressCollector = new DownloadProgressCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
collectors.DownloadProgressIntervalSeconds
);
_downloadProgressCollector.Start();
}
if (collectors.EnableCertValidationCollector)
{
_certValidationCollector = new CertValidationCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_certValidationCollector.Start();
}
if (collectors.EnableEspUiStateCollector)
{
_espUiStateCollector = new EspUiStateCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
collectors.EspUiStateIntervalSeconds
);
_espUiStateCollector.Start();
_enrollmentTracker.Start();
}
}
catch (Exception ex)
@@ -323,17 +279,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
_performanceCollector?.Dispose();
_performanceCollector = null;
_downloadProgressCollector?.Stop();
_downloadProgressCollector?.Dispose();
_downloadProgressCollector = null;
_certValidationCollector?.Stop();
_certValidationCollector?.Dispose();
_certValidationCollector = null;
_espUiStateCollector?.Stop();
_espUiStateCollector?.Dispose();
_espUiStateCollector = null;
_enrollmentTracker?.Stop();
_enrollmentTracker?.Dispose();
_enrollmentTracker = null;
}
/// <summary>
@@ -354,7 +302,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
_logger,
_configuration.ImeLogPathOverride
);
_gatherRuleExecutor.UpdateRules(config.GatherRules);
}
@@ -374,6 +323,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
try
{
// Hot-reload IME log patterns without restart
if (_enrollmentTracker != null && newConfig.ImeLogPatterns != null)
{
_enrollmentTracker.UpdatePatterns(newConfig.ImeLogPatterns);
}
// Stop and restart optional collectors with new settings
StopOptionalCollectors();
StartOptionalCollectors();
@@ -415,7 +370,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
OsEdition = GetOsEdition(),
OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
StartedAt = DateTime.UtcNow,
AgentVersion = "1.0.0-phase1"
AgentVersion = "1.0.0"
};
var response = await _apiClient.RegisterSessionAsync(registration);
@@ -532,7 +487,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
EventType = "agent_stopped",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = _phaseDetector?.CurrentPhase ?? EnrollmentPhase.PreFlight,
Phase = _lastPhase ?? EnrollmentPhase.Unknown,
Message = "Autopilot Monitor Agent stopped"
});
@@ -551,15 +506,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
try
{
_eventLogWatcher?.Stop();
_eventLogWatcher?.Dispose();
_registryMonitor?.Stop();
_registryMonitor?.Dispose();
_phaseDetector?.Stop();
_phaseDetector?.Dispose();
_helloDetector?.Stop();
_helloDetector?.Dispose();
@@ -603,12 +549,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
// Notify gather rule executor of phase change
try { _gatherRuleExecutor?.OnPhaseChanged(evt.Phase); } catch { }
// Re-run cert validation on network phase completion
if (evt.Phase == EnrollmentPhase.Identity && _certValidationCollector != null)
{
try { _certValidationCollector.RunValidation(); } catch { }
}
}
// Notify gather rule executor of event type (for on_event triggers)
@@ -659,11 +599,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
// 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)
evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true ||
isAppEvent)
{
_logger.Debug("Critical/Phase event detected, triggering immediate upload (bypassing debounce)");
_logger.Debug($"Critical/Phase/App event detected ({evt.EventType}), triggering immediate upload (bypassing debounce)");
Task.Run(() => UploadEventsAsync());
}
}
@@ -956,15 +900,10 @@ Restart-Computer -Force
_debounceTimer?.Dispose();
_apiClient?.Dispose();
_spool?.Dispose();
_eventLogWatcher?.Dispose();
_registryMonitor?.Dispose();
_phaseDetector?.Dispose();
_helloDetector?.Dispose();
_simulator?.Dispose();
_performanceCollector?.Dispose();
_downloadProgressCollector?.Dispose();
_certValidationCollector?.Dispose();
_espUiStateCollector?.Dispose();
_enrollmentTracker?.Dispose();
_gatherRuleExecutor?.Dispose();
_remoteConfigService?.Dispose();
}
@@ -1,7 +1,7 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.Core.Storage
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Manages persistent session ID storage to maintain tracking across agent restarts
@@ -12,7 +12,7 @@ using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.Core.Api
namespace AutopilotMonitor.Agent.Core.Monitoring.Network
{
/// <summary>
/// Client for communicating with the backend API
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.Core.Network
namespace AutopilotMonitor.Agent.Core.Monitoring.Network
{
public class GeoLocationResult
{
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Simulation
{
/// <summary>
/// Simulates Autopilot enrollment using real IME log replay and real device data.
///
/// Key principle: Real data, no mocks.
/// - Device info: collected from actual WMI/Registry on the machine running the simulator
/// - App tracking: replayed from real IME log files with time-compressed delays
/// - Performance data: handled by PerformanceCollector (always on, real machine data)
/// </summary>
public class AutopilotSimulator : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly bool _simulateFailure;
private readonly string _simulationLogDirectory;
private readonly double _speedFactor;
private readonly List<ImeLogPattern> _imeLogPatterns;
private CancellationTokenSource _cancellationTokenSource;
private Task _simulationTask;
private EnrollmentTracker _enrollmentTracker;
private bool _disposed = false;
public AutopilotSimulator(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
bool simulateFailure = false,
string simulationLogDirectory = null,
double speedFactor = 50,
List<ImeLogPattern> imeLogPatterns = null)
{
_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));
_simulateFailure = simulateFailure;
_simulationLogDirectory = simulationLogDirectory;
_speedFactor = speedFactor;
_imeLogPatterns = imeLogPatterns ?? new List<ImeLogPattern>();
}
/// <summary>
/// Starts the Autopilot simulation
/// </summary>
public void Start()
{
_logger.Info("Starting Autopilot Simulator (log replay mode)");
if (string.IsNullOrEmpty(_simulationLogDirectory) || !Directory.Exists(_simulationLogDirectory))
{
_logger.Warning($"Simulation log directory not found or not specified: {_simulationLogDirectory}");
_logger.Info("Simulator will only emit device info events (no log replay)");
}
_cancellationTokenSource = new CancellationTokenSource();
_simulationTask = Task.Run(() => RunSimulation(_cancellationTokenSource.Token));
}
/// <summary>
/// Stops the simulation
/// </summary>
public void Stop()
{
if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested)
return;
_logger.Info("Stopping Autopilot Simulator");
try
{
_cancellationTokenSource?.Cancel();
_simulationTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (ObjectDisposedException) { }
catch (AggregateException) { }
}
private async Task RunSimulation(CancellationToken cancellationToken)
{
try
{
_logger.Info("Starting simulated Autopilot enrollment (real device data + log replay)");
// Create EnrollmentTracker in simulation mode
// This collects REAL device info from this machine and replays IME logs
// The EnrollmentTracker will emit all real events from log replay
_enrollmentTracker = new EnrollmentTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_imeLogPatterns,
_simulationLogDirectory,
simulationMode: true,
speedFactor: _speedFactor
);
_enrollmentTracker.Start();
_logger.Info($"EnrollmentTracker started in simulation mode (speed factor: {_speedFactor}x)");
// Wait for log replay to complete or be cancelled
// The ImeLogTracker will parse all log files and then the polling loop will idle
// We monitor the app tracking state to know when to complete
var checkInterval = TimeSpan.FromSeconds(5);
var maxWaitTime = TimeSpan.FromMinutes(10); // Safety timeout
var elapsed = TimeSpan.Zero;
var allAppsCompleted = false;
while (!cancellationToken.IsCancellationRequested && elapsed < maxWaitTime)
{
await Task.Delay(checkInterval, cancellationToken);
elapsed += checkInterval;
// Check if all apps have been tracked and completed
var packageStates = _enrollmentTracker.ImeTracker?.PackageStates;
if (packageStates != null && packageStates.CountAll > 0 && packageStates.IsAllCompleted())
{
allAppsCompleted = true;
_logger.Info($"Simulator: all {packageStates.CountAll} apps completed after {elapsed.TotalSeconds:F0}s");
break;
}
}
if (cancellationToken.IsCancellationRequested) return;
// Note: enrollment_complete event is now emitted by EnrollmentTracker
// when it detects "Completed user session" in the IME log.
// We only emit failure events here if simulation fails.
if (_simulateFailure && !allAppsCompleted)
{
EmitEvent("enrollment_failed", "Enrollment failed: app installation timeout",
EventSeverity.Critical, EnrollmentPhase.Failed,
new Dictionary<string, object>
{
{ "failedPhase", "AppInstallation" },
{ "reason", "App installation did not complete within timeout" }
});
}
_logger.Info("Simulated Autopilot enrollment replay completed (enrollment_complete will be emitted when IME log shows user session completion)");
}
catch (OperationCanceledException)
{
_logger.Info("Autopilot simulation cancelled");
}
catch (Exception ex)
{
_logger.Error("Error in Autopilot simulation", ex);
}
}
private void EmitEvent(string eventType, string message, EventSeverity severity,
EnrollmentPhase phase, Dictionary<string, object> data = null)
{
try
{
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "Simulator",
Phase = phase,
Message = message,
Data = data ?? new Dictionary<string, object>()
});
_logger.Debug($"Simulator event: {eventType} - {message}");
}
catch (Exception ex)
{
_logger.Error($"Error emitting simulator event: {eventType}", ex);
}
}
public void Dispose()
{
if (_disposed)
return;
Stop();
_enrollmentTracker?.Dispose();
_cancellationTokenSource?.Dispose();
_disposed = true;
}
}
}
@@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Installation state of an app package during enrollment.
/// Order matters: states are compared numerically for upgrade-only transitions.
/// </summary>
public enum AppInstallationState
{
Unknown = 0,
NotInstalled = 1,
InProgress = 2,
Downloading = 3,
Installing = 4,
Installed = 5,
Skipped = 6,
Postponed = 7,
Error = 8
}
/// <summary>
/// How the app should run
/// </summary>
public enum AppRunAs
{
Unknown = -1,
User = 0,
System = 1
}
/// <summary>
/// App install intent from Intune policy
/// </summary>
public enum AppIntent
{
Unknown = -1,
NotTargeted = 0,
Available = 1,
Install = 3,
Uninstall = 4
}
/// <summary>
/// How the app is targeted
/// </summary>
[Flags]
public enum AppTargeted
{
Unknown = 128,
Dependency = 0,
User = 1,
Device = 2
}
/// <summary>
/// Win32 app state values from IME
/// </summary>
public enum Win32AppState
{
Unknown = 0,
NotInstalled = 1,
InProgress = 2,
Completed = 3,
Error = 4
}
/// <summary>
/// Represents the state of a single app package during enrollment.
/// Adapted from EspOverlay PackageState with state transition protection.
/// </summary>
public class AppPackageState : IComparable<AppPackageState>
{
public static bool SortErrorsToTop = false;
public string Id { get; }
public int ListPos { get; }
public string Name { get; private set; }
public AppRunAs RunAs { get; private set; } = AppRunAs.Unknown;
public AppIntent Intent { get; private set; } = AppIntent.Unknown;
public AppTargeted Targeted { get; private set; } = AppTargeted.Unknown;
public HashSet<string> DependsOn { get; private set; } = new HashSet<string>();
public AppInstallationState InstallationState { get; private set; } = AppInstallationState.Unknown;
public long InstallationStateLastChangedTicks { get; private set; } = 0;
public bool DownloadingOrInstallingSeen { get; private set; } = false;
public int? ProgressPercent { get; private set; } = null;
public long BytesDownloaded { get; private set; } = 0;
public long BytesTotal { get; private set; } = 0;
private static readonly Dictionary<Win32AppState, AppInstallationState> Win32StateMap =
new Dictionary<Win32AppState, AppInstallationState>
{
{ Win32AppState.Unknown, AppInstallationState.Unknown },
{ Win32AppState.InProgress, AppInstallationState.InProgress },
{ Win32AppState.Completed, AppInstallationState.Installed },
{ Win32AppState.Error, AppInstallationState.Error }
};
private static readonly Dictionary<AppInstallationState, int> SortOrderOverrides =
new Dictionary<AppInstallationState, int>
{
{ AppInstallationState.Skipped, (int)AppInstallationState.Installed },
{ AppInstallationState.Postponed, (int)AppInstallationState.Installed },
{ AppInstallationState.Error, (int)AppInstallationState.Installed }
};
public AppPackageState(string id, int listPos)
{
Id = id;
ListPos = listPos;
}
/// <summary>
/// Updates the app name. Only updates if the new name is not a truncated version of the current name.
/// </summary>
public bool UpdateName(string newName)
{
if (string.IsNullOrEmpty(newName) || string.Equals(Name, newName))
return false;
// Avoid overwriting with a truncated version
if (Name != null && newName.Length > 0 && Name.StartsWith(newName))
return false;
Name = newName;
return true;
}
/// <summary>
/// Updates RunAs property
/// </summary>
public bool UpdateRunAs(AppRunAs newRunAs)
{
if (RunAs == newRunAs) return false;
RunAs = newRunAs;
return true;
}
/// <summary>
/// Updates Intent property
/// </summary>
public bool UpdateIntent(AppIntent newIntent)
{
if (Intent == newIntent) return false;
Intent = newIntent;
return true;
}
/// <summary>
/// Updates Targeted property
/// </summary>
public bool UpdateTargeted(AppTargeted newTargeted)
{
if (Targeted == newTargeted) return false;
Targeted = newTargeted;
return true;
}
/// <summary>
/// Updates DependsOn set
/// </summary>
public bool UpdateDependsOn(HashSet<string> newDependsOn)
{
if (newDependsOn == null) newDependsOn = new HashSet<string>();
if (DependsOn.SetEquals(newDependsOn)) return false;
DependsOn = newDependsOn;
return true;
}
/// <summary>
/// Updates the installation state with transition protection.
/// Returns true if the state actually changed.
/// </summary>
public bool UpdateState(AppInstallationState newState, int? newProgressPercent = null, bool upgradeOnly = false, long bytesDownloaded = 0, long bytesTotal = 0)
{
// upgradeOnly: only allow "higher" states (used by Win32AppState mapping)
if (upgradeOnly && newState < InstallationState)
return false;
// Cannot downgrade from Installed to Skipped or Postponed
if (InstallationState == AppInstallationState.Installed &&
(newState == AppInstallationState.Skipped || newState == AppInstallationState.Postponed))
return false;
// Postponed cannot go back to Downloading or lower active states
if (InstallationState == AppInstallationState.Postponed && newState <= AppInstallationState.Downloading)
return false;
// If "Installed" without ever seeing download/install -> auto-downgrade to Skipped
// This handles apps with "inverse" detection rules (e.g., uninstall packages marked as
// installed when old software is NOT detected)
if (newState == AppInstallationState.Installed && !DownloadingOrInstallingSeen)
newState = AppInstallationState.Skipped;
// Skip if no actual change
if (newState == InstallationState && newProgressPercent == ProgressPercent && bytesDownloaded == BytesDownloaded && bytesTotal == BytesTotal)
return false;
var oldState = InstallationState;
InstallationState = newState;
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp();
DownloadingOrInstallingSeen |= (InstallationState >= AppInstallationState.Downloading &&
InstallationState <= AppInstallationState.Installing);
ProgressPercent = newProgressPercent;
BytesDownloaded = bytesDownloaded;
BytesTotal = bytesTotal;
return true;
}
/// <summary>
/// Updates state from a Win32AppState string or integer string (e.g., "2" or "InProgress").
/// Uses upgrade-only mode to prevent "InProgress" from destroying more detailed states.
/// </summary>
public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString)
{
Win32AppState win32State;
if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State))
return UpdateStateFromWin32AppState(win32State);
return false;
}
/// <summary>
/// Updates state from Win32AppState enum value.
/// </summary>
public bool UpdateStateFromWin32AppState(Win32AppState win32State)
{
AppInstallationState newState;
if (Win32StateMap.TryGetValue(win32State, out newState))
return UpdateState(newState, upgradeOnly: true);
return false;
}
/// <summary>
/// Whether this app requires installation (Install or Uninstall intent)
/// </summary>
public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall;
/// <summary>
/// Whether the app is currently being processed (InProgress, Downloading, or Installing)
/// </summary>
public bool IsActive => InstallationState >= AppInstallationState.InProgress &&
InstallationState <= AppInstallationState.Installing;
/// <summary>
/// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error)
/// </summary>
public bool IsCompleted => InstallationState >= AppInstallationState.Installed;
/// <summary>
/// Whether the app is in an error state
/// </summary>
public bool IsError => InstallationState == AppInstallationState.Error;
/// <summary>
/// Comparison for sorting: active apps first, then by state change time, then by list position.
/// </summary>
public int CompareTo(AppPackageState other)
{
// First: descending on sort order (active apps at top)
var result = -(SortOrder.CompareTo(other.SortOrder));
if (result == 0)
{
// Then: by installation state last changed
result = InstallationStateLastChangedTicks.CompareTo(other.InstallationStateLastChangedTicks);
}
if (result == 0)
{
// Then: by original position in policy list
result = ListPos.CompareTo(other.ListPos);
}
return result;
}
private int SortOrder
{
get
{
if (SortErrorsToTop && InstallationState == AppInstallationState.Error)
return -1;
int sortOrder;
if (SortOrderOverrides.TryGetValue(InstallationState, out sortOrder))
return sortOrder;
return (int)InstallationState;
}
}
/// <summary>
/// Returns a summary dictionary for event data
/// </summary>
public Dictionary<string, object> ToEventData()
{
return new Dictionary<string, object>
{
{ "appId", Id },
{ "name", Name ?? Id },
{ "appName", Name ?? Id },
{ "state", InstallationState.ToString() },
{ "intent", Intent.ToString() },
{ "targeted", Targeted.ToString() },
{ "runAs", RunAs.ToString() },
{ "progressPercent", ProgressPercent ?? 0 },
{ "bytesDownloaded", BytesDownloaded },
{ "bytesTotal", BytesTotal },
{ "isError", IsError },
{ "isCompleted", IsCompleted }
};
}
}
}
@@ -0,0 +1,406 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.Core.Logging;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Manages a collection of app package states during enrollment.
/// Tracks current app, ignore list, and dependency cascading.
/// Adapted from EspOverlay PackageStatesList.
/// </summary>
public class AppPackageStateList : List<AppPackageState>
{
/// <summary>
/// The ID of the app currently being processed by IME
/// </summary>
public string CurrentPackageId { get; set; }
/// <summary>
/// Apps to ignore (e.g., user-targeted apps during device phase)
/// </summary>
public List<string> IgnoreList { get; } = new List<string>();
private readonly AgentLogger _logger;
// States that should also cascade to dependents
private static readonly HashSet<AppInstallationState> StatesToCascade =
new HashSet<AppInstallationState>
{
AppInstallationState.Postponed,
AppInstallationState.Error
};
public AppPackageStateList(AgentLogger logger)
{
_logger = logger;
}
/// <summary>
/// Gets a package by ID, optionally creating it if not found.
/// Returns null if the ID is in the ignore list.
/// </summary>
public AppPackageState GetPackage(string id, bool createIfNotFound = false)
{
if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id))
return null;
var result = this.SingleOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
if (result == null && createIfNotFound)
{
result = new AppPackageState(id, Count);
Add(result);
}
return result;
}
/// <summary>
/// Adds/updates packages from a JSON policy list (from IME "Get policies" log line).
/// Returns true if any package was updated.
/// </summary>
public bool AddUpdateFromJsonPolicies(List<Dictionary<string, object>> jsonPolicies, bool ignoreUserTargeted)
{
if (jsonPolicies == null) return false;
var updated = false;
foreach (var policy in jsonPolicies)
{
string id;
object idObj;
if (!policy.TryGetValue("Id", out idObj) || (id = idObj?.ToString()) == null)
continue;
// Check RunAs - ignore user-targeted during device phase
if (ignoreUserTargeted)
{
var runAs = GetRunAsFromPolicy(policy);
if (runAs == AppRunAs.User)
{
updated |= AddToIgnoreList(id);
continue;
}
}
var pkg = GetPackage(id, createIfNotFound: true);
if (pkg == null) continue;
// Update name
object nameObj;
if (policy.TryGetValue("Name", out nameObj))
updated |= pkg.UpdateName(nameObj?.ToString());
// Update intent
object intentObj;
if (policy.TryGetValue("Intent", out intentObj))
{
AppIntent intent;
if (Enum.TryParse(intentObj.ToString(), true, out intent))
updated |= pkg.UpdateIntent(intent);
}
// Update targeted
object targetObj;
if (policy.TryGetValue("TargetType", out targetObj))
{
AppTargeted targeted;
if (Enum.TryParse(targetObj.ToString(), true, out targeted))
updated |= pkg.UpdateTargeted(targeted);
}
// Update RunAs from InstallEx
object installExObj;
if (policy.TryGetValue("InstallEx", out installExObj))
{
var runAs = ExtractRunAsFromInstallEx(installExObj);
if (runAs != AppRunAs.Unknown)
updated |= pkg.UpdateRunAs(runAs);
}
// Update dependencies from FlatDependencies
object flatDepsObj;
if (policy.TryGetValue("FlatDependencies", out flatDepsObj))
{
var deps = ExtractDependencies(flatDepsObj, id);
if (deps != null)
updated |= pkg.UpdateDependsOn(deps);
}
}
if (updated)
{
Sort();
LogStates();
}
return updated;
}
/// <summary>
/// Updates the name of a specific package
/// </summary>
public bool UpdateName(string id, string newName)
{
var pkg = GetPackage(id);
if (pkg == null) return false;
var updated = pkg.UpdateName(newName);
if (updated) LogStates();
return updated;
}
/// <summary>
/// Updates the installation state of a specific package.
/// Returns true if state actually changed.
/// Optionally cascades to dependent packages for error/postpone states.
/// </summary>
public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null)
{
var packageIds = new HashSet<string> { id };
// Cascade error/postpone to dependents
if (StatesToCascade.Contains(newState))
packageIds.UnionWith(GetDependentIdsDeep(id));
var packages = packageIds.Select(x => GetPackage(x)).Where(x => x != null).ToList();
var result = false;
foreach (var pkg in packages)
result |= pkg.UpdateState(newState, progressPercent);
if (result)
{
AppPackageState.SortErrorsToTop = IsAllCompleted();
Sort();
}
return result;
}
/// <summary>
/// Updates state to Downloading with optional progress tracking
/// </summary>
public bool UpdateStateToDownloading(string id, string bytesDownloadedStr, string totalBytesStr)
{
int? progressPercent = null;
long bytesDownloaded = 0;
long bytesTotal = 0;
try
{
bytesDownloaded = Convert.ToInt64(bytesDownloadedStr);
bytesTotal = Convert.ToInt64(totalBytesStr);
if (bytesTotal != 0)
progressPercent = (int)Math.Round((double)bytesDownloaded / bytesTotal * 100);
}
catch { }
var pkg = GetPackage(id);
if (pkg != null &&
pkg.InstallationState != AppInstallationState.Skipped &&
pkg.InstallationState != AppInstallationState.Installed)
{
var result = pkg.UpdateState(AppInstallationState.Downloading, progressPercent, false, bytesDownloaded, bytesTotal);
if (result)
{
AppPackageState.SortErrorsToTop = IsAllCompleted();
Sort();
}
return result;
}
return false;
}
/// <summary>
/// Updates state from a Win32AppState string
/// </summary>
public bool UpdateStateFromWin32AppState(string id, string win32AppStateString)
{
var pkg = GetPackage(id);
if (pkg == null) return false;
var updated = pkg.UpdateStateFromWin32AppState(win32AppStateString);
if (updated)
{
AppPackageState.SortErrorsToTop = IsAllCompleted();
Sort();
}
return updated;
}
/// <summary>
/// Sets the current package being processed
/// </summary>
public void SetCurrent(string id)
{
CurrentPackageId = id;
}
/// <summary>
/// Adds an ID to the ignore list
/// </summary>
public bool AddToIgnoreList(string id)
{
if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id))
return false;
IgnoreList.Add(id);
return true;
}
/// <summary>
/// Checks if all required apps are completed
/// </summary>
public bool IsAllCompleted()
{
if (!this.Any()) return false;
var allRequiredComplete = this.Where(x => x.IsRequired).All(x => x.IsCompleted);
if (allRequiredComplete)
{
// Mark dependency-only packages that IME never touched as Skipped
foreach (var pkg in this.Where(x => !x.IsRequired && x.InstallationState == AppInstallationState.Unknown))
pkg.UpdateState(AppInstallationState.Skipped);
}
return allRequiredComplete;
}
/// <summary>
/// Gets the first non-completed package (for display)
/// </summary>
public AppPackageState ActivePackage => this.FirstOrDefault(x => !x.IsCompleted);
public int CountAll => Count;
public int CountCompleted => this.Count(x => x.IsCompleted);
public bool HasError => this.Any(x => x.IsError);
public int ErrorCount => this.Count(x => x.IsError);
/// <summary>
/// Gets summary data for event emission
/// </summary>
public Dictionary<string, object> GetSummaryData()
{
return new Dictionary<string, object>
{
{ "totalApps", CountAll },
{ "completedApps", CountCompleted },
{ "errorCount", ErrorCount },
{ "hasErrors", HasError },
{ "isAllCompleted", IsAllCompleted() },
{ "ignoredCount", IgnoreList.Count },
{ "apps", this.Select(x => x.ToEventData()).ToList() }
};
}
/// <summary>
/// Recursively gets all dependent package IDs (packages that depend on the given parent)
/// </summary>
private HashSet<string> GetDependentIdsDeep(string parentId, int level = 0)
{
var result = new HashSet<string>();
try
{
level++;
if (level > 10)
{
_logger?.Warning("Dependency tree deeper than 10 levels - possible circular dependency");
return result;
}
foreach (var dependent in this.Where(x => x.DependsOn.Contains(parentId)).Select(x => x.Id))
{
result.Add(dependent);
result.UnionWith(GetDependentIdsDeep(dependent, level));
}
}
catch (Exception ex)
{
_logger?.Warning($"Error resolving dependencies for {parentId}: {ex.Message}");
}
return result;
}
private static AppRunAs GetRunAsFromPolicy(Dictionary<string, object> policy)
{
try
{
object installExObj;
if (policy.TryGetValue("InstallEx", out installExObj))
return ExtractRunAsFromInstallEx(installExObj);
}
catch { }
return AppRunAs.Unknown;
}
private static AppRunAs ExtractRunAsFromInstallEx(object installExObj)
{
try
{
if (installExObj is Dictionary<string, object> installEx)
{
object runAsObj;
if (installEx.TryGetValue("RunAs", out runAsObj))
return (AppRunAs)Convert.ToInt32(runAsObj);
}
// Handle as JSON string
var str = installExObj?.ToString();
if (!string.IsNullOrEmpty(str))
{
var json = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(str);
if (json != null)
{
object runAsObj;
if (json.TryGetValue("RunAs", out runAsObj))
return (AppRunAs)Convert.ToInt32(runAsObj.ToString());
}
}
}
catch { }
return AppRunAs.Unknown;
}
private static HashSet<string> ExtractDependencies(object flatDepsObj, string appId)
{
try
{
if (flatDepsObj == null) return new HashSet<string>();
var deps = flatDepsObj as List<object>;
if (deps == null) return new HashSet<string>();
var result = new HashSet<string>();
foreach (var dep in deps)
{
var depDict = dep as Dictionary<string, object>;
if (depDict == null) continue;
object actionObj, appIdObj, childIdObj, typeObj, levelObj;
if (depDict.TryGetValue("Action", out actionObj) &&
depDict.TryGetValue("AppId", out appIdObj) &&
depDict.TryGetValue("ChildId", out childIdObj) &&
depDict.TryGetValue("Type", out typeObj) &&
depDict.TryGetValue("Level", out levelObj))
{
if (Convert.ToInt32(actionObj) == 10 &&
string.Equals(appIdObj?.ToString(), appId, StringComparison.OrdinalIgnoreCase) &&
Convert.ToInt32(typeObj) == 0 &&
Convert.ToInt32(levelObj) == 0)
{
result.Add(childIdObj.ToString());
}
}
}
return result;
}
catch { }
return new HashSet<string>();
}
private void LogStates()
{
if (_logger == null) return;
var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}"));
_logger.Debug($"App states [{Count}]: {stateList}");
}
}
}
@@ -0,0 +1,123 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Represents a single parsed line from a CMTrace-format log file
/// </summary>
public class CmTraceLogEntry
{
public DateTime Timestamp { get; set; }
public string Message { get; set; }
public string Component { get; set; }
public int Type { get; set; } // 1=Info, 2=Warning, 3=Error
public string Thread { get; set; }
}
/// <summary>
/// Parses CMTrace/SCCM log format used by IME and other Microsoft components.
/// Format: &lt;![LOG[{message}]LOG]!&gt;&lt;time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file=""&gt;
/// </summary>
public static class CmTraceLogParser
{
// Pre-compiled regex for the CMTrace format
private static readonly Regex CmTraceRegex = new Regex(
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
RegexOptions.Compiled | RegexOptions.Singleline
);
/// <summary>
/// Attempts to parse a single line of CMTrace-formatted log.
/// Returns true if parsing succeeded, false if the line doesn't match the format.
/// </summary>
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
return false;
var match = CmTraceRegex.Match(line);
if (!match.Success)
return false;
var message = match.Groups["message"].Value;
var timeStr = match.Groups["time"].Value;
var dateStr = match.Groups["date"].Value;
var component = match.Groups["component"].Value;
var typeStr = match.Groups["type"].Value;
var thread = match.Groups["thread"].Value;
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
DateTime timestamp;
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
{
timestamp = DateTime.UtcNow;
}
int type;
int.TryParse(typeStr, out type);
entry = new CmTraceLogEntry
{
Timestamp = timestamp,
Message = message,
Component = component,
Type = type,
Thread = thread
};
return true;
}
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
{
result = DateTime.MinValue;
// Date format: "M-d-yyyy" (e.g., "2-8-2026")
// Time format: "HH:mm:ss.ticks" (e.g., "06:08:04.8834397")
// Truncate time to 7 fractional digits max for DateTime parsing
var timeParts = timeStr.Split('.');
string normalizedTime;
if (timeParts.Length == 2)
{
var fraction = timeParts[1];
if (fraction.Length > 7)
fraction = fraction.Substring(0, 7);
normalizedTime = timeParts[0] + "." + fraction;
}
else
{
normalizedTime = timeStr;
}
var combined = dateStr + " " + normalizedTime;
// Try multiple date formats to handle varying date styles
string[] formats = new[]
{
"M-d-yyyy H:mm:ss.fffffff",
"M-d-yyyy H:mm:ss.ffffff",
"M-d-yyyy H:mm:ss.fffff",
"M-d-yyyy H:mm:ss.ffff",
"M-d-yyyy H:mm:ss.fff",
"M-d-yyyy H:mm:ss.ff",
"M-d-yyyy H:mm:ss.f",
"M-d-yyyy H:mm:ss",
"M-d-yyyy HH:mm:ss.fffffff",
"M-d-yyyy HH:mm:ss.ffffff",
"M-d-yyyy HH:mm:ss.fffff",
"M-d-yyyy HH:mm:ss.ffff",
"M-d-yyyy HH:mm:ss.fff",
"M-d-yyyy HH:mm:ss.ff",
"M-d-yyyy HH:mm:ss.f",
"M-d-yyyy HH:mm:ss"
};
return DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out result);
}
}
}
@@ -0,0 +1,699 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Management;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Central enrollment tracking orchestrator.
/// Collects consolidated device info events at startup and manages ImeLogTracker
/// for smart app installation tracking with strategic event emission.
/// </summary>
public class EnrollmentTracker : IDisposable
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
private readonly string _imeLogFolder;
private readonly List<ImeLogPattern> _imeLogPatterns;
private ImeLogTracker _imeLogTracker;
private Timer _summaryTimer;
private bool _summaryTimerActive;
private string _lastEspPhase; // Track last ESP phase to prevent duplicate events
private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase
// Default IME log folder
private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
public EnrollmentTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger,
List<ImeLogPattern> imeLogPatterns,
string imeLogFolderOverride = null,
bool simulationMode = false,
double speedFactor = 50)
{
_sessionId = sessionId;
_tenantId = tenantId;
_emitEvent = emitEvent;
_logger = logger;
_imeLogPatterns = imeLogPatterns ?? new List<ImeLogPattern>();
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
// Create ImeLogTracker
_imeLogTracker = new ImeLogTracker(_imeLogFolder, _imeLogPatterns, _logger);
_imeLogTracker.SimulationMode = simulationMode;
_imeLogTracker.SpeedFactor = speedFactor;
// Wire up callbacks
_imeLogTracker.OnEspPhaseChanged = HandleEspPhaseChanged;
_imeLogTracker.OnImeAgentVersion = HandleImeAgentVersion;
_imeLogTracker.OnImeStarted = HandleImeStarted;
_imeLogTracker.OnAppStateChanged = HandleAppStateChanged;
_imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered;
_imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted;
_imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted;
}
/// <summary>
/// Starts the enrollment tracker: collects device info and starts IME log tracking.
/// </summary>
public void Start()
{
_logger.Info("EnrollmentTracker: starting");
// Collect and emit consolidated device info events
CollectDeviceInfo();
// Start IME log tracking
_imeLogTracker.Start();
// Start periodic summary timer (30s, starts when app tracking begins)
_summaryTimer = new Timer(SummaryTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
_logger.Info("EnrollmentTracker: started");
}
/// <summary>
/// Stops the enrollment tracker
/// </summary>
public void Stop()
{
_logger.Info("EnrollmentTracker: stopping");
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_imeLogTracker?.Stop();
_logger.Info("EnrollmentTracker: stopped");
}
/// <summary>
/// Updates IME log patterns (hot-reload from config change)
/// </summary>
public void UpdatePatterns(List<ImeLogPattern> newPatterns)
{
if (newPatterns != null)
{
_logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)");
_imeLogTracker?.CompilePatterns(newPatterns);
}
}
/// <summary>
/// Access to the ImeLogTracker (for simulator to reference package states)
/// </summary>
public ImeLogTracker ImeTracker => _imeLogTracker;
// ===== Device Info Collection =====
private void CollectDeviceInfo()
{
_logger.Info("EnrollmentTracker: collecting device info");
CollectNetworkAdapters();
CollectDnsConfiguration();
CollectProxyConfiguration();
CollectAutopilotProfile();
CollectSecureBootStatus();
CollectBitLockerStatus();
CollectAadJoinStatus();
}
private void CollectNetworkAdapters()
{
try
{
var adapters = 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())
{
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);
}
}
EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
new Dictionary<string, object>
{
{ "adapterCount", adapters.Count },
{ "adapters", adapters }
});
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect network adapters: {ex.Message}");
}
}
private void CollectDnsConfiguration()
{
try
{
var dnsServers = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
"SELECT Description, DNSServerSearchOrder FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
{
foreach (ManagementObject obj in searcher.Get())
{
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("dns_configuration", "DNS server configuration",
new Dictionary<string, object>
{
{ "dnsEntries", dnsServers }
});
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect 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}");
}
}
private void CollectAutopilotProfile()
{
try
{
var data = new Dictionary<string, object>();
// Read CloudAssigned* values from AutopilotSettings
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"))
{
if (key != null)
{
data["cloudAssignedTenantId"] = key.GetValue("CloudAssignedTenantId")?.ToString();
data["cloudAssignedTenantDomain"] = key.GetValue("CloudAssignedTenantDomain")?.ToString();
data["cloudAssignedOobeConfig"] = key.GetValue("CloudAssignedOobeConfig")?.ToString();
}
}
// Read deployment profile name
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\AutoPilot"))
{
if (key != null)
{
data["deploymentProfileName"] = key.GetValue("DeploymentProfileName")?.ToString();
}
}
EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
}
}
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 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}");
}
}
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
});
}
// ===== ImeLogTracker Callbacks -> Strategic Events =====
private void HandleEspPhaseChanged(string phase)
{
// 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
// 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 } }
});
// Start summary timer when we detect ESP phase
if (!_summaryTimerActive)
{
_summaryTimerActive = true;
_summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}
}
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:
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
}
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: {newState}",
Data = app.ToEventData()
});
}
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 summary timer
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
// Emit final summary
EmitAppTrackingSummary();
// If we're in AppsDevice phase, we might transition back to DeviceSetup or to AccountSetup
// If we're in AppsUser phase, enrollment is likely about to complete
// For now, just log - the IME log will show the next phase change
if (_lastEspPhase != null)
{
_logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'");
}
}
private void HandleUserSessionCompleted()
{
_logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
// Stop summary timer if running
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
// Emit enrollment completed event
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_complete",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = EnrollmentPhase.Complete,
Message = "Autopilot enrollment completed successfully (user session completed)"
});
}
private void SummaryTimerCallback(object state)
{
if (_imeLogTracker?.PackageStates?.CountAll > 0)
{
EmitAppTrackingSummary();
}
}
private void EmitAppTrackingSummary()
{
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
_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()
});
}
public void Dispose()
{
Stop();
_summaryTimer?.Dispose();
_imeLogTracker?.Dispose();
}
}
}
@@ -0,0 +1,602 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Agent.Core.Monitoring.Simulation;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
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.
///
/// 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
{
private readonly string _logFolder;
private readonly AgentLogger _logger;
private readonly LogFilePositionTracker _positionTracker = new LogFilePositionTracker();
private readonly AppPackageStateList _packageStates;
private readonly int _pollingIntervalMs;
// Compiled pattern matchers grouped by category
private List<CompiledPattern> _patternsAlways = new List<CompiledPattern>();
private List<CompiledPattern> _patternsCurrentPhase = new List<CompiledPattern>();
private List<CompiledPattern> _patternsOtherPhases = new List<CompiledPattern>();
// Active matchers (changes based on ESP phase)
private List<CompiledPattern> _activePatterns = new List<CompiledPattern>();
private bool _logPhaseIsCurrentPhase = false;
// Simulation mode
public bool SimulationMode { get; set; }
public double SpeedFactor { get; set; } = 50;
private DateTime _lastLogTimestamp = DateTime.MinValue;
// Background task
private Task _pollingTask;
private CancellationTokenSource _cts;
// Standard GUID capture pattern used as {GUID} placeholder in patterns
private const string GuidPattern = @"(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})";
// Log files to monitor
private static readonly string[] LogFilePatterns = new[]
{
"IntuneManagementExtension.log",
"_IntuneManagementExtension.log",
"IntuneManagementExtension-????????-??????.log",
"AppWorkload.log",
"AppWorkload-????????-??????.log"
};
// Callbacks to EnrollmentTracker
public Action<string> OnEspPhaseChanged { get; set; }
public Action<string> OnImeAgentVersion { get; set; }
public Action OnImeStarted { get; set; }
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
public Action<string> OnPoliciesDiscovered { get; set; }
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
/// <summary>
/// Access to the tracked package states
/// </summary>
public AppPackageStateList PackageStates => _packageStates;
public ImeLogTracker(string logFolder, List<ImeLogPattern> patterns, AgentLogger logger, int pollingIntervalMs = 100)
{
_logFolder = Environment.ExpandEnvironmentVariables(logFolder);
_logger = logger;
_pollingIntervalMs = pollingIntervalMs;
_packageStates = new AppPackageStateList(logger);
CompilePatterns(patterns);
ActivatePatterns(logPhaseIsCurrentPhase: false, force: true);
}
/// <summary>
/// Compiles ImeLogPattern list from backend into Regex objects grouped by category.
/// </summary>
public void CompilePatterns(List<ImeLogPattern> patterns)
{
_patternsAlways = new List<CompiledPattern>();
_patternsCurrentPhase = new List<CompiledPattern>();
_patternsOtherPhases = new List<CompiledPattern>();
if (patterns == null) return;
foreach (var pattern in patterns.Where(p => p.Enabled))
{
try
{
// Replace {GUID} placeholder with actual GUID capture regex
var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern);
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled);
var compiled = new CompiledPattern
{
PatternId = pattern.PatternId,
Regex = regex,
Action = pattern.Action,
Parameters = pattern.Parameters ?? new Dictionary<string, string>()
};
switch (pattern.Category?.ToLower())
{
case "always":
_patternsAlways.Add(compiled);
break;
case "currentphase":
_patternsCurrentPhase.Add(compiled);
break;
case "otherphases":
_patternsOtherPhases.Add(compiled);
break;
default:
_patternsAlways.Add(compiled); // Default to always
break;
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to compile IME pattern {pattern.PatternId}: {ex.Message}");
}
}
_logger.Info($"ImeLogTracker: compiled {_patternsAlways.Count} always, {_patternsCurrentPhase.Count} currentPhase, {_patternsOtherPhases.Count} otherPhases patterns");
// Re-activate with current phase state
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
}
/// <summary>
/// Starts the background polling task
/// </summary>
public void Start()
{
if (_pollingTask != null) return;
_cts = new CancellationTokenSource();
var token = _cts.Token;
_pollingTask = Task.Run(async () =>
{
_logger.Info("ImeLogTracker: polling started");
while (!token.IsCancellationRequested)
{
try
{
await CheckLogFilesAsync(token);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: error during log check: {ex.Message}");
try { await Task.Delay(1000, token); } catch (OperationCanceledException) { break; }
}
try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; }
}
_logger.Info("ImeLogTracker: polling stopped");
}, token);
}
/// <summary>
/// Stops the background polling task
/// </summary>
public void Stop()
{
_cts?.Cancel();
try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { }
_pollingTask = null;
}
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)
{
var match = pattern.Regex.Match(messageToMatch);
if (match.Success)
{
HandlePatternMatch(pattern, match, messageToMatch);
}
}
}
}
_positionTracker.SetPosition(filePath, stream.Position);
}
}
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;
switch (pattern.Action?.ToLower())
{
case "imestarted":
HandleImeStarted();
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);
}
else if (!string.IsNullOrEmpty(id))
{
UpdateStateWithCallback(id, AppInstallationState.Error);
}
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;
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}");
}
}
private void HandleImeStarted()
{
_logger.Info("ImeLogTracker: IME Agent Started detected");
// Mark any currently active app as installed (it was running before restart)
if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
{
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg?.IsActive == true)
UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Installed);
}
OnImeStarted?.Invoke();
}
private void HandleEspPhaseDetected(string espPhaseString)
{
bool logPhaseIsCurrentPhase;
if (string.Equals(espPhaseString, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
logPhaseIsCurrentPhase = true; // We treat DeviceSetup as current phase
else if (string.Equals(espPhaseString, "AccountSetup", StringComparison.OrdinalIgnoreCase))
logPhaseIsCurrentPhase = true; // AccountSetup is also current phase
else
return; // Not a valid/relevant phase
_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;
if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
{
_packageStates.SetCurrent(id);
UpdateStateWithCallback(id, AppInstallationState.Installing);
}
else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
{
UpdateStateWithCallback(id, AppInstallationState.Installed);
}
else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
{
UpdateStateWithCallback(id, AppInstallationState.Error);
}
}
private void HandlePoliciesDiscovered(string policiesJson)
{
try
{
var policies = 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))
{
UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
}
_packageStates.SetCurrent(newId);
}
private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateState(id, newState, progressPercent);
if (changed)
{
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
OnAppStateChanged?.Invoke(pkg, oldState, newState);
// Check if all apps are now completed
if (_packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
{
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();
}
/// <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,75 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
/// <summary>
/// Tracks file read positions for incremental log file reading.
/// In-memory only - resets on agent restart, which is acceptable since
/// we start fresh per enrollment session.
/// </summary>
public class LogFilePositionTracker
{
private readonly Dictionary<string, FilePositionState> _positions
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the safe read position for a file, detecting rollover/truncation.
/// If the current file size is smaller than the stored position, the file
/// has been rotated - resets to 0 to read from beginning.
/// Returns 0 if no position has been recorded yet.
/// </summary>
public long GetSafePosition(string filePath, long currentFileSize)
{
FilePositionState state;
if (!_positions.TryGetValue(filePath, out state))
return 0;
// Detect rollover: file is smaller than our stored position
if (currentFileSize < state.Position)
{
state.Position = 0;
state.LastKnownSize = currentFileSize;
return 0;
}
return state.Position;
}
/// <summary>
/// Stores the current read position for a file after successful reading.
/// </summary>
public void SetPosition(string filePath, long position)
{
FilePositionState state;
if (!_positions.TryGetValue(filePath, out state))
{
state = new FilePositionState();
_positions[filePath] = state;
}
state.Position = position;
state.LastKnownSize = position; // Position is always <= file size
state.LastReadTime = DateTime.UtcNow;
}
/// <summary>
/// Gets the stored position for a file, or 0 if not tracked.
/// Does not perform rollover detection.
/// </summary>
public long GetPosition(string filePath)
{
FilePositionState state;
if (_positions.TryGetValue(filePath, out state))
return state.Position;
return 0;
}
}
internal class FilePositionState
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
public DateTime LastReadTime { get; set; }
}
}
@@ -2,8 +2,7 @@ using System;
using System.ServiceProcess;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring;
using AutopilotMonitor.Agent.Core.Storage;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
namespace AutopilotMonitor.Agent
{
+46 -3
View File
@@ -5,8 +5,7 @@ using System.Linq;
using System.ServiceProcess;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring;
using AutopilotMonitor.Agent.Core.Storage;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
using Microsoft.Win32;
using Newtonsoft.Json;
@@ -56,6 +55,11 @@ namespace AutopilotMonitor.Agent
{
Console.WriteLine($"Simulator Mode: ENABLED");
Console.WriteLine($"Simulate Failure: {(config.SimulateFailure ? "YES" : "NO")}");
if (!string.IsNullOrEmpty(config.SimulationLogDirectory))
{
Console.WriteLine($"Simulation Log Dir: {config.SimulationLogDirectory}");
Console.WriteLine($"Simulation Speed: {config.SimulationSpeedFactor}x");
}
}
if (config.RebootOnComplete)
@@ -282,10 +286,14 @@ namespace AutopilotMonitor.Agent
var noCleanup = args?.Contains("--no-cleanup") ?? false;
var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false;
var disableGeoLocation = args?.Contains("--disable-geolocation") ?? false;
var newSession = args?.Contains("--new-session") ?? false;
// Parse certificate thumbprint if provided
string certThumbprint = null;
string tenantIdOverride = null;
string imeLogPathOverride = null;
string simulationLogDirectory = null;
double simulationSpeedFactor = 50;
if (args != null)
{
var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
@@ -299,6 +307,30 @@ namespace AutopilotMonitor.Agent
{
tenantIdOverride = args[tenantIdIndex + 1];
}
var imeLogPathIndex = Array.IndexOf(args, "--ime-log-path");
if (imeLogPathIndex >= 0 && imeLogPathIndex + 1 < args.Length)
{
imeLogPathOverride = args[imeLogPathIndex + 1];
Console.WriteLine($"Using custom IME log path: {imeLogPathOverride}");
}
var simLogDirIndex = Array.IndexOf(args, "--simulation-log-dir");
if (simLogDirIndex >= 0 && simLogDirIndex + 1 < args.Length)
{
simulationLogDirectory = args[simLogDirIndex + 1];
Console.WriteLine($"Using simulation log directory: {simulationLogDirectory}");
}
var simSpeedIndex = Array.IndexOf(args, "--simulation-speed-factor");
if (simSpeedIndex >= 0 && simSpeedIndex + 1 < args.Length)
{
if (double.TryParse(args[simSpeedIndex + 1], out var speed))
{
simulationSpeedFactor = speed;
Console.WriteLine($"Using simulation speed factor: {simulationSpeedFactor}x");
}
}
}
// Defaults (fallback values if no config file exists)
@@ -391,6 +423,14 @@ namespace AutopilotMonitor.Agent
// Load or create persisted session ID
var dataDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
var sessionPersistence = new SessionPersistence(dataDirectory);
// Force new session if --new-session flag is set
if (newSession)
{
Console.WriteLine("--new-session flag detected, deleting existing session...");
sessionPersistence.DeleteSession();
}
var sessionId = sessionPersistence.LoadOrCreateSessionId();
var sessionStatus = sessionPersistence.SessionExists() && File.GetCreationTime(Path.Combine(dataDirectory, "session.id")) < DateTime.Now.AddMinutes(-1)
@@ -415,7 +455,10 @@ namespace AutopilotMonitor.Agent
CleanupOnExit = cleanupOnExit,
SelfDestructOnComplete = selfDestructOnComplete,
RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig,
EnableGeoLocation = !disableGeoLocation && enableGeoLocationConfig
EnableGeoLocation = !disableGeoLocation && enableGeoLocationConfig,
ImeLogPathOverride = imeLogPathOverride,
SimulationLogDirectory = simulationLogDirectory,
SimulationSpeedFactor = simulationSpeedFactor
};
}
}
@@ -75,17 +75,15 @@ namespace AutopilotMonitor.Functions.Functions
var collectors = new CollectorConfiguration
{
EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector,
PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds,
EnableDownloadProgressCollector = tenantConfig.EnableDownloadProgressCollector,
DownloadProgressIntervalSeconds = tenantConfig.DownloadProgressCollectorIntervalSeconds,
EnableCertValidationCollector = tenantConfig.EnableCertValidationCollector,
EnableEspUiStateCollector = tenantConfig.EnableEspUiStateCollector,
EspUiStateIntervalSeconds = tenantConfig.EspUiStateCollectorIntervalSeconds
PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds
};
// Get active gather rules for this tenant
// Get active gather rules for this tenant (user-defined ad-hoc only)
var gatherRules = await _gatherRuleService.GetActiveRulesForTenantAsync(tenantId);
// Get built-in IME log patterns for smart enrollment tracking
var imeLogPatterns = BuiltInImeLogPatterns.GetAll();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
@@ -93,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions
Message = "Configuration retrieved successfully",
Collectors = collectors,
GatherRules = gatherRules,
ImeLogPatterns = imeLogPatterns,
ConfigVersion = 1,
RefreshIntervalSeconds = 300
});
@@ -123,7 +123,7 @@ namespace AutopilotMonitor.Functions.Functions
foreach (var evt in storedEvents)
{
// Track special events for session status updates
if (evt.EventType == "phase_changed")
if (evt.EventType == "phase_changed" || evt.EventType == "esp_phase_changed")
{
lastPhaseChangeEvent = evt;
}
@@ -3,230 +3,26 @@ using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Built-in gather rules shipped with the system
/// These define what data the agent collects beyond the core collectors
/// Built-in gather rules shipped with the system.
///
/// NOTE: All network, identity, device, ESP, and IME log parsing rules have been moved
/// to the EnrollmentTracker (direct WMI/Registry collection) and ImeLogTracker (IME log
/// patterns via BuiltInImeLogPatterns). GatherRules now serve only as a mechanism for
/// user-defined ad-hoc collection rules.
/// </summary>
public static class BuiltInGatherRules
{
public static List<GatherRule> GetAll()
{
return new List<GatherRule>
{
// ===== NETWORK RULES =====
new GatherRule
{
RuleId = "GATHER-NET-001",
Title = "Collect Proxy Settings",
Description = "Reads WinHTTP and Internet Explorer proxy configuration from the registry. Helps diagnose proxy-related enrollment failures.",
Category = "network",
CollectorType = "registry",
Target = @"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings",
Parameters = new Dictionary<string, string>
{
{ "values", "ProxyServer,ProxyEnable,ProxyOverride,AutoConfigURL" }
},
Trigger = "startup",
OutputEventType = "gather_proxy_settings",
Tags = new[] { "network", "proxy", "common" },
Author = "Autopilot Monitor"
},
new GatherRule
{
RuleId = "GATHER-NET-002",
Title = "Collect DNS Configuration",
Description = "Queries DNS client server addresses to verify DNS resolution capability.",
Category = "network",
CollectorType = "command",
Target = "Get-DnsClientServerAddress | Select-Object InterfaceAlias, ServerAddresses",
Trigger = "startup",
OutputEventType = "gather_dns_config",
Tags = new[] { "network", "dns" },
Author = "Autopilot Monitor"
},
new GatherRule
{
RuleId = "GATHER-NET-003",
Title = "Collect Network Adapters",
Description = "Lists all network adapters and their status via WMI.",
Category = "network",
CollectorType = "wmi",
Target = "SELECT Name, NetConnectionStatus, Speed, MACAddress FROM Win32_NetworkAdapter WHERE NetConnectionStatus IS NOT NULL",
Trigger = "startup",
OutputEventType = "gather_network_adapters",
Tags = new[] { "network", "adapters" },
Author = "Autopilot Monitor"
},
// ===== IDENTITY RULES =====
new GatherRule
{
RuleId = "GATHER-ID-001",
Title = "Collect AAD Join Status",
Description = "Reads Azure AD join state from the registry to verify device identity.",
Category = "identity",
CollectorType = "registry",
Target = @"HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
Parameters = new Dictionary<string, string>
{
{ "recurse", "true" }
},
Trigger = "phase_change",
TriggerPhase = "Identity",
OutputEventType = "gather_aad_join_status",
Tags = new[] { "identity", "aad", "entra" },
Author = "Autopilot Monitor"
},
new GatherRule
{
RuleId = "GATHER-ID-002",
Title = "Collect Device Certificates",
Description = "Lists certificates in the machine personal store to verify MDM and device certificates.",
Category = "identity",
CollectorType = "command",
Target = "certutil -store My",
Trigger = "phase_change",
TriggerPhase = "Identity",
OutputEventType = "gather_device_certificates",
Tags = new[] { "identity", "certificates" },
Author = "Autopilot Monitor"
},
// ===== MDM ENROLLMENT RULES =====
new GatherRule
{
RuleId = "GATHER-MDM-001",
Title = "Collect MDM Enrollment Status",
Description = "Reads MDM enrollment registry keys to verify Intune enrollment state.",
Category = "enrollment",
CollectorType = "registry",
Target = @"HKLM:\SOFTWARE\Microsoft\Enrollments",
Parameters = new Dictionary<string, string>
{
{ "recurse", "true" },
{ "maxDepth", "2" }
},
Trigger = "phase_change",
TriggerPhase = "MdmEnrollment",
OutputEventType = "gather_mdm_enrollment_status",
Tags = new[] { "enrollment", "mdm", "intune" },
Author = "Autopilot Monitor"
},
// ===== APP INSTALLATION RULES =====
new GatherRule
{
RuleId = "GATHER-APP-001",
Title = "Collect IME App Inventory",
Description = "Reads the Intune Management Extension app inventory from registry.",
Category = "apps",
CollectorType = "registry",
Target = @"HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps",
Parameters = new Dictionary<string, string>
{
{ "recurse", "true" },
{ "maxDepth", "2" }
},
Trigger = "phase_change",
TriggerPhase = "AppInstallation",
OutputEventType = "gather_ime_app_inventory",
Tags = new[] { "apps", "ime", "win32" },
Author = "Autopilot Monitor"
},
new GatherRule
{
RuleId = "GATHER-APP-002",
Title = "Collect Win32App Detection Results",
Description = "Periodically checks Win32 app detection script results during app installation phase.",
Category = "apps",
CollectorType = "registry",
Target = @"HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps",
Parameters = new Dictionary<string, string>
{
{ "recurse", "true" },
{ "filter", "DetectionState" }
},
Trigger = "interval",
IntervalSeconds = 60,
OutputEventType = "gather_win32app_detection",
Tags = new[] { "apps", "detection", "win32" },
Author = "Autopilot Monitor"
},
// ===== ESP RULES =====
new GatherRule
{
RuleId = "GATHER-ESP-001",
Title = "Collect ESP Tracking Policies",
Description = "Reads ESP tracking policy list from registry to identify blocking apps and policies.",
Category = "esp",
CollectorType = "registry",
Target = @"HKLM:\SOFTWARE\Microsoft\Enrollments",
Parameters = new Dictionary<string, string>
{
{ "recurse", "true" },
{ "filter", "FirstSync" }
},
Trigger = "phase_change",
TriggerPhase = "EspDeviceSetup",
OutputEventType = "gather_esp_tracking_policies",
Tags = new[] { "esp", "tracking", "blocking" },
Author = "Autopilot Monitor"
},
// ===== DEVICE RULES =====
new GatherRule
{
RuleId = "GATHER-DEV-001",
Title = "Collect TPM Status",
Description = "Queries TPM status via WMI to verify TPM readiness for Autopilot.",
Category = "device",
CollectorType = "wmi",
Target = "SELECT * FROM Win32_Tpm",
Parameters = new Dictionary<string, string>
{
{ "namespace", @"root\CIMV2\Security\MicrosoftTpm" }
},
Trigger = "startup",
OutputEventType = "gather_tpm_status",
Tags = new[] { "device", "tpm", "security" },
Author = "Autopilot Monitor"
},
new GatherRule
{
RuleId = "GATHER-DEV-002",
Title = "Collect BitLocker Status",
Description = "Queries BitLocker volume encryption status.",
Category = "device",
CollectorType = "wmi",
Target = "SELECT * FROM Win32_EncryptableVolume",
Parameters = new Dictionary<string, string>
{
{ "namespace", @"root\CIMV2\Security\MicrosoftVolumeEncryption" }
},
Trigger = "phase_change",
TriggerPhase = "EspDeviceSetup",
OutputEventType = "gather_bitlocker_status",
Tags = new[] { "device", "bitlocker", "encryption" },
Author = "Autopilot Monitor"
},
new GatherRule
{
RuleId = "GATHER-DEV-003",
Title = "Collect SecureBoot Status",
Description = "Reads Secure Boot state from the registry.",
Category = "device",
CollectorType = "registry",
Target = @"HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State",
Parameters = new Dictionary<string, string>
{
{ "values", "UEFISecureBootEnabled" }
},
Trigger = "startup",
OutputEventType = "gather_secureboot_status",
Tags = new[] { "device", "secureboot", "security" },
Author = "Autopilot Monitor"
}
};
// No built-in rules - all data collection is now handled by:
// - EnrollmentTracker: device info (network, DNS, proxy, autopilot profile, SecureBoot, BitLocker, AAD join)
// - ImeLogTracker: IME log parsing with patterns from BuiltInImeLogPatterns
// - PerformanceCollector: CPU/memory/disk (always on)
// - CertValidationCollector: certificate validation (optional toggle)
// - EventLogWatcher, RegistryMonitor, PhaseDetector, HelloDetector: core collectors
//
// GatherRules remain as a mechanism for user-defined ad-hoc collection only.
return new List<GatherRule>();
}
}
}
@@ -0,0 +1,457 @@
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Built-in IME log regex patterns for smart enrollment tracking.
/// These patterns are adapted from the EspOverlay project's proven IME log parsing logic.
/// Delivered to the agent via the config API so patterns can be updated without agent rebuild.
///
/// Pattern uses {GUID} as placeholder which the agent expands to the standard GUID capture regex.
/// Named capture groups (e.g., (?&lt;id&gt;...)) are passed to the action handler.
/// </summary>
public static class BuiltInImeLogPatterns
{
public static List<ImeLogPattern> GetAll()
{
return new List<ImeLogPattern>
{
// ============================================================
// CATEGORY: always (active regardless of ESP phase)
// ============================================================
new ImeLogPattern
{
PatternId = "IME-STARTED",
Category = "always",
Pattern = @"EMS Agent Started",
Action = "imeStarted"
},
new ImeLogPattern
{
PatternId = "IME-SESSION-CHANGE",
Category = "always",
Pattern = @"OnSessionChange:? (?<change>.*?)$",
Action = "imeSessionChange"
},
new ImeLogPattern
{
PatternId = "IME-ESP-PHASE",
Category = "always",
Pattern = @"\[Win32App\] (?:In|The) EspPhase: (?<espPhase>\w+)",
Action = "espPhaseDetected"
},
new ImeLogPattern
{
PatternId = "IME-TOKEN-SUCCESS",
Category = "always",
Pattern = @"Successfully get the token",
Action = "espPhaseDetected",
Parameters = new Dictionary<string, string> { { "phase", "AccountSetup" } }
},
new ImeLogPattern
{
PatternId = "IME-SET-CURRENT-1",
Category = "always",
Pattern = @"\[Win32App\] ProcessAppWithDependencies starts for {GUID} with name",
Action = "setCurrentApp"
},
new ImeLogPattern
{
PatternId = "IME-SET-CURRENT-2",
Category = "always",
Pattern = @"\[Win32App\] ExecManager: processing targeted app.*?\bid\W+{GUID}",
Action = "setCurrentApp"
},
new ImeLogPattern
{
PatternId = "IME-SET-CURRENT-3",
Category = "always",
Pattern = @"\[Win32App\] ===Step=== Start to Present app {GUID}",
Action = "setCurrentApp"
},
new ImeLogPattern
{
PatternId = "IME-SET-CURRENT-4",
Category = "always",
Pattern = @"\[Win32App\] SetCurrentDirectory:.*?\\{GUID}_\d+",
Action = "setCurrentApp"
},
new ImeLogPattern
{
PatternId = "IME-AGENT-VERSION",
Category = "always",
Pattern = @"Agent version is: (?<agentVersion>[\d.]+)",
Action = "imeAgentVersion"
},
new ImeLogPattern
{
PatternId = "IME-IMPERSONATION",
Category = "always",
Pattern = @"After impersonation: (?<user>.*?)$",
Action = "imeImpersonation"
},
new ImeLogPattern
{
PatternId = "IME-USER-SESSION-COMPLETED",
Category = "always",
Pattern = @"\[Win32App\] \.+ Completed user session \d+, userId: (?<userId>[a-f0-9\-]+), userSID: (?<userSid>S-[\d\-]+) \.+",
Action = "enrollmentCompleted"
},
// ============================================================
// CATEGORY: currentPhase (active during current ESP phase)
// ============================================================
// --- Shutdown / Postpone ---
new ImeLogPattern
{
PatternId = "IME-SHUTDOWN",
Category = "currentPhase",
Pattern = @"EMS Agent received shutdown signal",
Action = "updateStatePostponed",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
// --- ESP Track Status (new logs) ---
new ImeLogPattern
{
PatternId = "IME-ESP-TRACK-STATUS",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[EspManager\] Updating ESP tracked install status from (?<from>\w+) to (?<to>\w+) for application {GUID}\.",
Action = "espTrackStatus"
},
// --- Skipped / Not Applicable ---
new ImeLogPattern
{
PatternId = "IME-NO-ACTION",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ActionProcessor\] No action required for app with id: {GUID}",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-NOT-APPLICABLE-1",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ApplicabilityActionHandler\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-NOT-APPLICABLE-2",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Applicability state for app with id: {GUID} has been updated\. Report delta: \{""ApplicabilityState"":\{""OldValue"":null,""NewValue"":""ScriptRequirementRuleNotMet""",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-NOT-APPLICABLE-3",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Desired state for app with id: {GUID} has been updated\. Report delta: \{""DesiredState"":\{""OldValue"":null,""NewValue"":""None""",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-NOT-APPLICABLE-4",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Desired state for app with id: {GUID} has been updated\. Report delta: \{""DesiredState"":\{""OldValue"":null,""NewValue"":""NotPresent""",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-NOT-TARGETED-1",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ActionProcessor\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\.",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-NOT-TARGETED-2",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ActionProcessor\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\.",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-SKIP-FILTERS",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[V3Processor\] All apps in the subgraph are not applicable due to assignment filters\. Skipping processing\.",
Action = "updateStateSkipped",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
// --- Old log format: Skipped patterns ---
new ImeLogPattern
{
PatternId = "IME-SKIP-UNINSTALL-NOTDETECTED",
Category = "currentPhase",
Pattern = @"\[Win32App\] Not detected app {GUID} and intent is to un-install, skip applicability check",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-SKIP-INSTALL-DETECTED",
Category = "currentPhase",
Pattern = @"\[Win32App\] Detected app {GUID} and intent is to install, skip applicability check",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-SKIP-APPLICABILITY-NOTMET",
Category = "currentPhase",
Pattern = @"\[Win32App\] (?=.*?\bapplicability\b)(?=.*?NotMet\b).*?\bapp\W+{GUID}\b",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-SKIP-UNINSTALL-NOTDETECTED-2",
Category = "currentPhase",
Pattern = @"\[Win32App\] NOT detected app {GUID}.*?skip un-installation",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-SKIP-DEPENDENCY-DETECT",
Category = "currentPhase",
Pattern = @"\[Win32App\] app\W+{GUID}\b.*?\bis a dependency\b.*?\bDetect Only mode\b.*?\bfinished processing",
Action = "updateStateSkipped"
},
new ImeLogPattern
{
PatternId = "IME-SKIP-DETECTED",
Category = "currentPhase",
Pattern = @"\[Win32App\] Detected app {GUID}.*?skip next step",
Action = "updateStateSkipped"
},
// --- Installed / Detected ---
new ImeLogPattern
{
PatternId = "IME-DETECTED-INSTALLED-1",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Detection state for app with id: {GUID} has been updated\. Report delta: \{""DetectionState"":\{""OldValue"":null,""NewValue"":""Installed""",
Action = "updateStateInstalled"
},
new ImeLogPattern
{
PatternId = "IME-DETECTED-INSTALLED-2",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[DetectionActionHandler\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
Action = "updateStateInstalled"
},
new ImeLogPattern
{
PatternId = "IME-DETECTED-OLD",
Category = "currentPhase",
Pattern = @"\[Win32App\] Detected app {GUID} ",
Action = "updateStateInstalled"
},
new ImeLogPattern
{
PatternId = "IME-ENFORCEMENT-SUCCESS",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Execution state for app with id: {GUID} has been updated\. Report delta: \{""EnforcementState"":\{""OldValue"":""InProgressDownloadCompleted"",""NewValue"":""Success""",
Action = "updateStateInstalled"
},
new ImeLogPattern
{
PatternId = "IME-ENFORCEMENT-SUCCESS-OLD",
Category = "currentPhase",
Pattern = @"\[Win32App\] Setting enforcementState as\W+Success\b",
Action = "updateStateInstalled",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
// --- Installing ---
new ImeLogPattern
{
PatternId = "IME-INSTALL-HANDLER",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ExecutionActionHandler\] Handler invoked with execution type: Install for policy with id: {GUID}",
Action = "updateStateInstalling"
},
new ImeLogPattern
{
PatternId = "IME-LAUNCH-INSTALLER",
Category = "currentPhase",
Pattern = @"\[Win32App\] Launch Win32AppInstaller in \w+? session",
Action = "updateStateInstalling",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
new ImeLogPattern
{
PatternId = "IME-WINGET-INSTALLING-1",
Category = "currentPhase",
Pattern = @"\[WinGetMessageProcessor\] Processing Progress for app = {GUID} and user = .*?- Operation Phase = Installing",
Action = "updateStateInstalling"
},
new ImeLogPattern
{
PatternId = "IME-WINGET-INSTALLING-2",
Category = "currentPhase",
Pattern = @"\[WinGetMessageProcessor\] Processing Progress for app = {GUID}.*?- Operation Phase = Installing",
Action = "updateStateInstalling"
},
// --- Downloading ---
new ImeLogPattern
{
PatternId = "IME-DOWNLOADING",
Category = "currentPhase",
Pattern = @"\[StatusService\] Downloading app \(id = {GUID}.*?\) via (?<tech>\w+), bytes (?<bytes>\w+)/(?<ofbytes>\w+) for user",
Action = "updateStateDownloading"
},
// --- Policies discovered ---
new ImeLogPattern
{
PatternId = "IME-POLICIES",
Category = "currentPhase",
Pattern = @"Get policies =\s*(?<policies>.*)",
Action = "policiesDiscovered"
},
// --- Update name (old log format) ---
new ImeLogPattern
{
PatternId = "IME-UPDATE-NAME-1",
Category = "currentPhase",
Pattern = @"\[Win32App\] ExecManager: processing targeted app.*?name\W+(?<name>.*?)\W+id\W+{GUID}",
Action = "updateName"
},
new ImeLogPattern
{
PatternId = "IME-UPDATE-NAME-2",
Category = "currentPhase",
Pattern = @"\[Win32App\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
Action = "updateName"
},
new ImeLogPattern
{
PatternId = "IME-UPDATE-NAME-3",
Category = "currentPhase",
Pattern = @"\[Win32App\] Processing app \(id={GUID}, name = (?<name>.*?)\)",
Action = "updateName"
},
// --- Win32AppState (old log format) ---
new ImeLogPattern
{
PatternId = "IME-WIN32-STATE-1",
Category = "currentPhase",
Pattern = @"\[Win32App\] Got InstanceID: Win32App_{GUID}_\d+, installationStateString: (?<state>\d)",
Action = "updateWin32AppState"
},
new ImeLogPattern
{
PatternId = "IME-WIN32-STATE-2",
Category = "currentPhase",
Pattern = @"\[Win32App\] (?:Got|Set) (?:ESP )?win32App state (?<state>\w+) for {GUID}",
Action = "updateWin32AppState"
},
// --- Error states ---
new ImeLogPattern
{
PatternId = "IME-DECRYPT-FAILED",
Category = "currentPhase",
Pattern = @"Decryption is failed for appId {GUID}",
Action = "updateStateError"
},
new ImeLogPattern
{
PatternId = "IME-ERROR-DOWNLOAD",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Download state for app with id: {GUID} has been updated\. Report delta: \{""EnforcementState"":\{""OldValue"":""InProgress"",""NewValue"":""ErrorDownloadingContent""",
Action = "updateStateError"
},
new ImeLogPattern
{
PatternId = "IME-ERROR-UNMAPPED-EXIT",
Category = "currentPhase",
Pattern = @"\[Win32App\] Admin did NOT set mapping for lpExitCode\W+\d+ of app\W+{GUID}",
Action = "updateStateError"
},
new ImeLogPattern
{
PatternId = "IME-ERROR-ENFORCEMENT",
Category = "currentPhase",
Pattern = @"\[Win32App\] Setting enforcementState as\W+Error\b",
Action = "updateStateError",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
new ImeLogPattern
{
PatternId = "IME-ERROR-TIMEOUT",
Category = "currentPhase",
Pattern = @"\[Win32App\] Installation is timeout",
Action = "updateStateError",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
new ImeLogPattern
{
PatternId = "IME-ERROR-NO-CONTENT",
Category = "currentPhase",
Pattern = @"\[Win32App\] Didn't get content info from service",
Action = "updateStateError",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
new ImeLogPattern
{
PatternId = "IME-ERROR-REPORT",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[ReportingManager\] Execution state for app with id: {GUID} has been updated\. Report delta: \{""EnforcementState"":\{""OldValue"":""(?<from>\w+)"",""NewValue"":""(?<to>\w+)""",
Action = "updateStateError",
Parameters = new Dictionary<string, string> { { "checkTo", "true" } }
},
// --- DO Timeout / Postpone ---
new ImeLogPattern
{
PatternId = "IME-DO-TIMEOUT-1",
Category = "currentPhase",
Pattern = @"\[Win32App DO\] DO download(?=.*?\bnot finished\b)(?=.*?\btimeout\b).*?intunewin-bin_{GUID}",
Action = "updateStatePostponed"
},
new ImeLogPattern
{
PatternId = "IME-DO-TIMEOUT-2",
Category = "currentPhase",
Pattern = @"\[Win32App DO\] DO download(?=.*?\bnot finished\b)(?=.*?\btimeout\b)",
Action = "updateStatePostponed",
Parameters = new Dictionary<string, string> { { "useCurrentApp", "true" } }
},
// --- Subgraph processing (cancel stuck + set current) ---
new ImeLogPattern
{
PatternId = "IME-SUBGRAPH",
Category = "currentPhase",
Pattern = @"\[Win32App\]\[V3Processor\] Processing subgraph with app ids: {GUID}",
Action = "cancelStuckAndSetCurrent"
},
// ============================================================
// CATEGORY: otherPhases (for apps completed in previous phases)
// ============================================================
new ImeLogPattern
{
PatternId = "IME-ALREADY-DETECTED-1",
Category = "otherPhases",
Pattern = @"\[Win32App\] Completed detectionManager SideCar\w+?DetectionManager, applicationDetectedByCurrentRule: True",
Action = "ignoreCompletedApp"
},
new ImeLogPattern
{
PatternId = "IME-ALREADY-DETECTED-2",
Category = "otherPhases",
Pattern = @"\[Win32App\] Setting enforcementState as\W+Success\b",
Action = "ignoreCompletedApp"
}
};
}
}
}
@@ -6,6 +6,7 @@ using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Services
{
@@ -105,7 +106,7 @@ namespace AutopilotMonitor.Functions.Services
["IsPreProvisioned"] = registration.IsPreProvisioned,
["StartedAt"] = registration.StartedAt,
["AgentVersion"] = registration.AgentVersion ?? string.Empty,
["CurrentPhase"] = (int)EnrollmentPhase.PreFlight,
["CurrentPhase"] = (int)EnrollmentPhase.Start,
["Status"] = SessionStatus.InProgress.ToString(),
["EventCount"] = 0
};
@@ -515,9 +516,7 @@ namespace AutopilotMonitor.Functions.Services
Phase = (EnrollmentPhase)(entity.GetInt32("Phase") ?? 0),
Message = entity.GetString("Message") ?? string.Empty,
Sequence = entity.GetInt64("Sequence") ?? 0,
Data = entity.ContainsKey("DataJson") && entity.GetString("DataJson") != null
? JsonConvert.DeserializeObject<Dictionary<string, object>>(entity.GetString("DataJson"))
: new Dictionary<string, object>()
Data = DeserializeEventData(entity.GetString("DataJson"))
};
}
@@ -1770,6 +1769,61 @@ namespace AutopilotMonitor.Functions.Services
return Array.Empty<string>();
}
}
/// <summary>
/// Deserializes event data JSON and converts JToken objects to native .NET types
/// </summary>
private Dictionary<string, object> DeserializeEventData(string? dataJson)
{
if (string.IsNullOrEmpty(dataJson))
return new Dictionary<string, object>();
try
{
var deserialized = JsonConvert.DeserializeObject<Dictionary<string, object>>(dataJson);
if (deserialized == null)
return new Dictionary<string, object>();
// Convert all JToken values to native types
var result = new Dictionary<string, object>();
foreach (var kvp in deserialized)
{
result[kvp.Key] = ConvertJTokenToNative(kvp.Value);
}
return result;
}
catch
{
return new Dictionary<string, object>();
}
}
/// <summary>
/// Converts JToken objects (JArray, JObject) to native .NET types
/// This fixes the issue where Newtonsoft.Json deserialization creates JToken objects
/// that get serialized incorrectly as nested empty arrays
/// </summary>
private object ConvertJTokenToNative(object value)
{
if (value is JArray jArray)
{
return jArray.Select(item => ConvertJTokenToNative(item)).ToList();
}
else 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;
}
else if (value is JValue jValue)
{
return jValue.Value ?? string.Empty;
}
return value;
}
}
public class AuditLogEntry
@@ -189,12 +189,7 @@ namespace AutopilotMonitor.Functions.Services
{ "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB },
{ "CustomSettings", config.CustomSettings },
{ "EnablePerformanceCollector", config.EnablePerformanceCollector },
{ "PerformanceCollectorIntervalSeconds", config.PerformanceCollectorIntervalSeconds },
{ "EnableDownloadProgressCollector", config.EnableDownloadProgressCollector },
{ "DownloadProgressCollectorIntervalSeconds", config.DownloadProgressCollectorIntervalSeconds },
{ "EnableCertValidationCollector", config.EnableCertValidationCollector },
{ "EnableEspUiStateCollector", config.EnableEspUiStateCollector },
{ "EspUiStateCollectorIntervalSeconds", config.EspUiStateCollectorIntervalSeconds }
{ "PerformanceCollectorIntervalSeconds", config.PerformanceCollectorIntervalSeconds }
};
return entity;
@@ -221,12 +216,7 @@ namespace AutopilotMonitor.Functions.Services
MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5,
CustomSettings = entity.GetString("CustomSettings"),
EnablePerformanceCollector = entity.GetBoolean("EnablePerformanceCollector") ?? false,
PerformanceCollectorIntervalSeconds = entity.GetInt32("PerformanceCollectorIntervalSeconds") ?? 30,
EnableDownloadProgressCollector = entity.GetBoolean("EnableDownloadProgressCollector") ?? false,
DownloadProgressCollectorIntervalSeconds = entity.GetInt32("DownloadProgressCollectorIntervalSeconds") ?? 10,
EnableCertValidationCollector = entity.GetBoolean("EnableCertValidationCollector") ?? false,
EnableEspUiStateCollector = entity.GetBoolean("EnableEspUiStateCollector") ?? false,
EspUiStateCollectorIntervalSeconds = entity.GetInt32("EspUiStateCollectorIntervalSeconds") ?? 15
PerformanceCollectorIntervalSeconds = entity.GetInt32("PerformanceCollectorIntervalSeconds") ?? 30
};
}
@@ -8,7 +8,7 @@ namespace AutopilotMonitor.Shared
/// <summary>
/// Agent version
/// </summary>
public const string AgentVersion = "1.0.0-phase1";
public const string AgentVersion = "1.0.0";
/// <summary>
/// Registry path for agent configuration
@@ -11,7 +11,18 @@ namespace AutopilotMonitor.Shared.Models
public bool Success { get; set; }
public string Message { get; set; }
public CollectorConfiguration Collectors { get; set; }
/// <summary>
/// User-defined ad-hoc gather rules (minimal set, not for IME log parsing)
/// </summary>
public List<GatherRule> GatherRules { get; set; } = new List<GatherRule>();
/// <summary>
/// IME log regex patterns for smart enrollment tracking.
/// Delivered from backend so patterns can be updated without agent rebuild.
/// </summary>
public List<ImeLogPattern> ImeLogPatterns { get; set; } = new List<ImeLogPattern>();
public int ConfigVersion { get; set; }
/// <summary>
@@ -23,17 +34,14 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Configuration for optional agent collectors
/// Core collectors (EventLogWatcher, RegistryMonitor, PhaseDetector, HelloDetector) are always on
/// </summary>
public class CollectorConfiguration
{
// ===== PERFORMANCE COLLECTOR =====
/// <summary>
/// Enable CPU, memory, disk, network performance monitoring
/// Enable CPU, memory, disk, network performance monitoring (always on for UI chart)
/// Generates traffic: ~1 event per interval
/// </summary>
public bool EnablePerformanceCollector { get; set; } = false;
public bool EnablePerformanceCollector { get; set; } = true;
/// <summary>
/// Interval in seconds for performance snapshots
@@ -41,45 +49,8 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int PerformanceIntervalSeconds { get; set; } = 60;
// ===== DOWNLOAD PROGRESS COLLECTOR =====
/// <summary>
/// Enable IME/Intune app download progress tracking
/// Generates traffic: ~1 event per interval per active download
/// </summary>
public bool EnableDownloadProgressCollector { get; set; } = false;
/// <summary>
/// Interval in seconds for download progress checks
/// Default: 15 seconds
/// </summary>
public int DownloadProgressIntervalSeconds { get; set; } = 15;
// ===== CERTIFICATE VALIDATION COLLECTOR =====
/// <summary>
/// Enable certificate chain validation for enrollment endpoints
/// Generates traffic: low (runs at startup + on network phase)
/// </summary>
public bool EnableCertValidationCollector { get; set; } = false;
// ===== ESP UI STATE COLLECTOR =====
/// <summary>
/// Enable ESP (Enrollment Status Page) UI state tracking
/// Captures blocking apps, progress, status text
/// Generates traffic: ~1 event per interval during ESP phases
/// </summary>
public bool EnableEspUiStateCollector { get; set; } = false;
/// <summary>
/// Interval in seconds for ESP UI state checks
/// Default: 30 seconds
/// </summary>
public int EspUiStateIntervalSeconds { get; set; } = 30;
/// <summary>
/// Creates default collector configuration (all optional collectors off)
/// Creates default collector configuration
/// </summary>
public static CollectorConfiguration CreateDefault()
{
@@ -53,9 +53,10 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Phase during which this event occurred (internal property, not serialized)
/// Defaults to Unknown (chronologically sorted into active phase)
/// </summary>
[JsonIgnore]
public EnrollmentPhase Phase { get; set; }
public EnrollmentPhase Phase { get; set; } = EnrollmentPhase.Unknown;
/// <summary>
/// Phase as number for JSON serialization (frontend expects number)
@@ -73,13 +74,12 @@ namespace AutopilotMonitor.Shared.Models
{
return phase switch
{
EnrollmentPhase.PreFlight => "PreFlight",
EnrollmentPhase.Network => "Network",
EnrollmentPhase.Identity => "Identity",
EnrollmentPhase.MdmEnrollment => "MDM Enrollment",
EnrollmentPhase.EspDeviceSetup => "ESP Device Setup",
EnrollmentPhase.AppInstallation => "App Installation",
EnrollmentPhase.EspUserSetup => "ESP User Setup",
EnrollmentPhase.Start => "Start",
EnrollmentPhase.DevicePreparation => "Device Preparation",
EnrollmentPhase.DeviceSetup => "Device Setup",
EnrollmentPhase.AppsDevice => "Apps (Device)",
EnrollmentPhase.AccountSetup => "Account Setup",
EnrollmentPhase.AppsUser => "Apps (User)",
EnrollmentPhase.Complete => "Complete",
EnrollmentPhase.Failed => "Failed",
_ => "Unknown"
@@ -6,44 +6,44 @@ namespace AutopilotMonitor.Shared.Models
public enum EnrollmentPhase
{
/// <summary>
/// Pre-flight: Agent deployed, session registered
/// Unknown: Events without explicit phase assignment (sorted chronologically into active phase)
/// </summary>
PreFlight = 0,
Unknown = -1,
/// <summary>
/// OOBE/Network: Network connection established
/// Start: Initial boot, OOBE begins
/// </summary>
Network = 1,
Start = 0,
/// <summary>
/// Identity: Azure AD/Entra joined, device certificate issued
/// Device Preparation: OOBE, network, identity, Autopilot profile applied
/// </summary>
Identity = 2,
DevicePreparation = 1,
/// <summary>
/// MDM Enrollment: Intune enrolled, policies starting
/// Device Setup: ESP Device phase, agent deployed/bootstrapped, policies applied
/// </summary>
MdmEnrollment = 3,
DeviceSetup = 2,
/// <summary>
/// ESP Device Setup: Device policies applied
/// Apps (Device): Required apps installing during device phase
/// </summary>
EspDeviceSetup = 4,
AppsDevice = 3,
/// <summary>
/// App Installation: Required apps installing
/// Account Setup: ESP User/Account phase, user sign-in
/// </summary>
AppInstallation = 5,
AccountSetup = 4,
/// <summary>
/// ESP User Setup: User policies and apps
/// Apps (User): User-targeted apps installing
/// </summary>
EspUserSetup = 6,
AppsUser = 5,
/// <summary>
/// Complete: Desktop reached
/// Complete: Desktop reached, enrollment succeeded
/// </summary>
Complete = 7,
Complete = 6,
/// <summary>
/// Failed: Enrollment failed
@@ -59,7 +59,7 @@ namespace AutopilotMonitor.Shared.Models
// ===== WHAT TO COLLECT =====
/// <summary>
/// Type of data collection: "registry", "eventlog", "wmi", "file", "command"
/// Type of data collection: "registry", "eventlog", "wmi", "file", "command", "logparser"
/// </summary>
public string CollectorType { get; set; }
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Shared.Models
/// - wmi: WMI query (e.g., "SELECT * FROM Win32_TPM")
/// - file: File path (e.g., "C:\Windows\INF\setupapi.dev.log")
/// - command: Command name from allowlist (e.g., "Get-TpmStatus")
/// - logparser: Log file path with env vars (e.g., "%ProgramData%\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log")
/// </summary>
public string Target { get; set; }
@@ -80,6 +81,7 @@ namespace AutopilotMonitor.Shared.Models
/// - wmi: { "namespace": "root\\CIMV2\\Security\\MicrosoftTpm" }
/// - file: { "maxLines": "100", "pattern": "error|fail" }
/// - command: { "arguments": "-Detailed" }
/// - logparser: { "pattern": "regex with (?&lt;named&gt;groups)", "logFormat": "cmtrace", "trackPosition": "true", "maxLines": "1000" }
/// </summary>
public Dictionary<string, string> Parameters { get; set; } = new Dictionary<string, string>();
@@ -0,0 +1,67 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Defines a regex pattern for IME log parsing.
/// Delivered from backend via agent config endpoint.
/// Allows updating patterns without agent rebuild when Microsoft changes IME log formats.
/// </summary>
public class ImeLogPattern
{
/// <summary>
/// Unique pattern identifier (e.g., "IME-ESP-PHASE")
/// </summary>
public string PatternId { get; set; }
/// <summary>
/// Pattern category controlling when the pattern is active:
/// - "always": Always active regardless of ESP phase
/// - "currentPhase": Only active during the current ESP phase
/// - "otherPhases": Only active during non-current ESP phases (for history/completed apps)
/// </summary>
public string Category { get; set; }
/// <summary>
/// Regex pattern string to match against IME log message content.
/// Supports named capture groups (e.g., (?&lt;id&gt;...)) which are passed to the action handler.
/// Uses {GUID} as placeholder for the standard GUID capture pattern.
/// </summary>
public string Pattern { get; set; }
/// <summary>
/// Action to perform when the pattern matches:
/// - "setCurrentApp": Set the current app being processed (uses 'id' capture group)
/// - "updateStateInstalled": Mark app as installed
/// - "updateStateDownloading": Mark app as downloading (uses 'bytes'/'ofbytes' for progress)
/// - "updateStateInstalling": Mark app as installing
/// - "updateStateSkipped": Mark app as skipped/not applicable
/// - "updateStateError": Mark app as errored
/// - "updateStatePostponed": Mark app as postponed (e.g., timeout)
/// - "espPhaseDetected": ESP phase transition detected (uses 'espPhase' capture group)
/// - "imeStarted": IME agent started
/// - "policiesDiscovered": App policies JSON discovered (uses 'policies' capture group)
/// - "ignoreCompletedApp": Add current app to ignore list (already completed in prior phase)
/// - "imeAgentVersion": IME version detected (uses 'agentVersion' capture group)
/// - "espTrackStatus": ESP tracked install status update (uses 'from'/'to'/'id' capture groups)
/// - "updateName": Update app name (uses 'id'/'name' capture groups)
/// - "updateWin32AppState": Update from Win32 app state (uses 'id'/'state' capture groups)
/// - "cancelStuckAndSetCurrent": Cancel stuck app and set new current (uses 'id' capture group)
/// </summary>
public string Action { get; set; }
/// <summary>
/// Optional extra parameters for the action handler.
/// Examples:
/// - { "phase": "AccountSetup" } for espPhaseDetected to force a specific phase
/// - { "useCurrentApp": "true" } to use CurrentPackageId instead of captured 'id'
/// - { "checkTo": "true" } to check the 'to' capture group value before applying state
/// </summary>
public Dictionary<string, string> Parameters { get; set; }
/// <summary>
/// Whether this pattern is enabled. Allows disabling patterns without removing them.
/// </summary>
public bool Enabled { get; set; } = true;
}
}
@@ -131,38 +131,6 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int PerformanceCollectorIntervalSeconds { get; set; } = 60;
/// <summary>
/// Enable Download Progress Collector (IME/Intune app download tracking)
/// Generates ~1 event per interval per active download
/// Default: false (opt-in)
/// </summary>
public bool EnableDownloadProgressCollector { get; set; } = false;
/// <summary>
/// Download progress collector interval in seconds
/// Default: 15 seconds
/// </summary>
public int DownloadProgressCollectorIntervalSeconds { get; set; } = 15;
/// <summary>
/// Enable Certificate Validation Collector (validates cert chains for enrollment endpoints)
/// Low traffic: runs at startup + on network phase change
/// Default: false (opt-in)
/// </summary>
public bool EnableCertValidationCollector { get; set; } = false;
/// <summary>
/// Enable ESP UI State Collector (captures ESP blocking apps, progress, status)
/// Generates ~1 event per interval during ESP phases only
/// Default: false (opt-in)
/// </summary>
public bool EnableEspUiStateCollector { get; set; } = false;
/// <summary>
/// ESP UI State collector interval in seconds
/// Default: 30 seconds
/// </summary>
public int EspUiStateCollectorIntervalSeconds { get; set; } = 30;
// ===== FUTURE SETTINGS (Extensible) =====
@@ -234,12 +202,7 @@ namespace AutopilotMonitor.Shared.Models
SessionTimeoutHours = 5,
MaxNdjsonPayloadSizeMB = 5,
EnablePerformanceCollector = false,
PerformanceCollectorIntervalSeconds = 60,
EnableDownloadProgressCollector = false,
DownloadProgressCollectorIntervalSeconds = 15,
EnableCertValidationCollector = false,
EnableEspUiStateCollector = false,
EspUiStateCollectorIntervalSeconds = 30
PerformanceCollectorIntervalSeconds = 60
};
}
}
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useSignalR } from "../../contexts/SignalRContext";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import DownloadProgress from "../../components/DownloadProgress";
interface Session {
sessionId: string;
@@ -34,14 +35,13 @@ interface EnrollmentEvent {
}
const phaseSteps = [
{ id: 0, label: "Device registered", shortLabel: "Registration" },
{ id: 1, label: "Network configured", shortLabel: "Network" },
{ id: 2, label: "Identity verified", shortLabel: "Identity" },
{ id: 3, label: "Management enrolled", shortLabel: "Enrollment" },
{ id: 4, label: "Security policies applied", shortLabel: "Policies" },
{ id: 5, label: "Installing apps", shortLabel: "Apps" },
{ id: 6, label: "User setup", shortLabel: "User Setup" },
{ id: 7, label: "Finalizing setup", shortLabel: "Complete" },
{ id: 0, label: "Setup start", shortLabel: "Start" },
{ id: 1, label: "Device preparation", shortLabel: "Preparation" },
{ id: 2, label: "Device setup", shortLabel: "Device" },
{ id: 3, label: "Installing apps (device)", shortLabel: "Apps (D)" },
{ id: 4, label: "Account setup", shortLabel: "Account" },
{ id: 5, label: "Installing apps (user)", shortLabel: "Apps (U)" },
{ id: 6, label: "Finalizing setup", shortLabel: "Complete" },
];
export default function ProgressPortalPage() {
@@ -58,7 +58,8 @@ export default function ProgressPortalPage() {
const { getAccessToken } = useAuth();
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const hasJoinedGroup = useRef(false);
const hasJoinedTenantGroup = useRef(false);
const hasJoinedSessionGroup = useRef(false);
const sessionRef = useRef<Session | null>(null);
// Keep ref in sync
@@ -68,34 +69,95 @@ export default function ProgressPortalPage() {
// Join tenant group for real-time updates
useEffect(() => {
if (isConnected && !hasJoinedGroup.current) {
if (isConnected && !hasJoinedTenantGroup.current) {
joinGroup(`tenant-${tenantId}`);
hasJoinedGroup.current = true;
hasJoinedTenantGroup.current = true;
}
return () => {
if (hasJoinedGroup.current) {
if (hasJoinedTenantGroup.current) {
leaveGroup(`tenant-${tenantId}`);
hasJoinedGroup.current = false;
hasJoinedTenantGroup.current = false;
}
};
}, [isConnected, tenantId]);
// Join session-specific group when session is found
useEffect(() => {
if (!isConnected || !session) {
return;
}
const sessionGroup = `session-${session.tenantId}-${session.sessionId}`;
console.log('[Progress] Joining session group:', sessionGroup);
joinGroup(sessionGroup);
hasJoinedSessionGroup.current = true;
return () => {
console.log('[Progress] Leaving session group:', sessionGroup);
leaveGroup(sessionGroup);
hasJoinedSessionGroup.current = false;
};
}, [isConnected, session?.sessionId, session?.tenantId, joinGroup, leaveGroup]);
// Listen for real-time session updates
useEffect(() => {
const handleNewEvents = (data: { session: Session }) => {
const handleNewEvents = (data: { session: Session; events?: EnrollmentEvent[] }) => {
console.log('[Progress] Received newevents:', data);
if (
data.session &&
sessionRef.current &&
data.session.sessionId === sessionRef.current.sessionId
) {
console.log('[Progress] Updating session from newevents');
setSession(data.session);
// Add new events to the events list
if (data.events && data.events.length > 0) {
console.log('[Progress] Adding', data.events.length, 'new events from newevents');
setEvents((prev) => {
const existingIds = new Set(prev.map((e) => e.eventId));
const newEvents = data.events!.filter((e) => !existingIds.has(e.eventId));
if (newEvents.length === 0) return prev;
return [...prev, ...newEvents].sort((a, b) => a.sequence - b.sequence);
});
}
}
};
const handleEventStream = (data: { session: Session; events?: EnrollmentEvent[] }) => {
console.log('[Progress] Received eventStream:', data);
if (
data.session &&
sessionRef.current &&
data.session.sessionId === sessionRef.current.sessionId
) {
console.log('[Progress] Updating session from eventStream');
console.log('[Progress] Session currentPhase:', data.session.currentPhase, 'status:', data.session.status);
setSession(data.session);
// Add new events to the events list
if (data.events && data.events.length > 0) {
console.log('[Progress] Adding', data.events.length, 'new events from eventStream');
console.log('[Progress] Event types:', data.events.map(e => e.eventType).join(', '));
setEvents((prev) => {
const existingIds = new Set(prev.map((e) => e.eventId));
const newEvents = data.events!.filter((e) => !existingIds.has(e.eventId));
if (newEvents.length === 0) return prev;
console.log('[Progress] Actually adding', newEvents.length, 'new events');
console.log('[Progress] Total events after update:', prev.length + newEvents.length);
return [...prev, ...newEvents].sort((a, b) => a.sequence - b.sequence);
});
}
}
};
on("newevents", handleNewEvents);
on("newSession", handleNewEvents);
on("eventStream", handleEventStream);
return () => {
off("newevents", handleNewEvents);
off("newSession", handleNewEvents);
off("eventStream", handleEventStream);
};
}, [on, off]);
@@ -248,20 +310,20 @@ export default function ProgressPortalPage() {
((session.currentPhase === 99
? 3
: session.currentPhase) /
7) *
6) *
100
)
: Math.min(100, (session.currentPhase / 7) * 100)
: Math.min(100, (session.currentPhase / 6) * 100)
: 0;
const estimatedRemaining = session
? (() => {
if (session.status !== "InProgress") return null;
const currentPhase = Math.min(session.currentPhase, 7);
const currentPhase = Math.min(session.currentPhase, 6);
if (currentPhase === 0) return null;
const elapsed = session.durationSeconds;
const rate = elapsed / currentPhase;
const remaining = (7 - currentPhase) * rate;
const remaining = (6 - currentPhase) * rate;
return Math.round(remaining / 60);
})()
: null;
@@ -483,7 +545,7 @@ export default function ProgressPortalPage() {
? 3
: session.currentPhase;
const isCompleted =
session.status === "Succeeded" ||
(session.status === "Succeeded" && step.id <= 6) ||
step.id < effectivePhase;
const isCurrent =
step.id === effectivePhase &&
@@ -555,14 +617,14 @@ export default function ProgressPortalPage() {
>
{step.label}
{isCurrent &&
step.id === 5 &&
(step.id === 3 || step.id === 5) &&
appProgress &&
appProgress.total > 0 &&
appProgress.completed > 0 &&
` (${appProgress.completed}/${appProgress.total})`}
</span>
{/* App install detail below the "Installing apps" step */}
{isCurrent && step.id === 5 && (
{/* App install detail below the "Installing apps" steps */}
{isCurrent && (step.id === 3 || step.id === 5) && (
<div className="flex items-center space-x-1.5 mt-0.5">
<div className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse flex-shrink-0" />
<span className="text-xs text-blue-500 truncate">
@@ -579,6 +641,20 @@ export default function ProgressPortalPage() {
})}
</div>
{/* Download Progress - Show during app installation phases */}
{session.status === "InProgress" &&
(session.currentPhase === 3 || session.currentPhase === 5) &&
events.filter(e => e.eventType === "download_progress").length > 0 && (
<div className="mb-8">
<DownloadProgress
events={events.filter(e => e.eventType === "download_progress").map(e => ({
timestamp: e.timestamp,
data: e.data
}))}
/>
</div>
)}
{/* Estimated Time / Status */}
{session.status === "InProgress" && estimatedRemaining != null && estimatedRemaining > 0 && (
<div className="bg-blue-50 rounded-lg p-4 text-center">
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState, useRef, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
@@ -54,14 +54,14 @@ interface Session {
}
const phaseNames: Record<number, string> = {
0: "PreFlight",
1: "Network",
2: "Identity",
3: "MDM Enrollment",
4: "ESP Device Setup",
5: "App Installation",
6: "ESP User Setup",
7: "Complete",
[-1]: "Unknown",
0: "Start",
1: "Device Preparation",
2: "Device Setup",
3: "Apps (Device)",
4: "Account Setup",
5: "Apps (User)",
6: "Complete",
99: "Failed"
};
@@ -73,8 +73,7 @@ export default function SessionDetailPage() {
const [session, setSession] = useState<Session | null>(null);
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
const [severityFilters, setSeverityFilters] = useState<Set<string>>(new Set(["Info", "Warning", "Error", "Critical", "Debug"]));
const [severityFilters, setSeverityFilters] = useState<Set<string>>(new Set(["Info", "Warning", "Error", "Critical"]));
const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false);
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
@@ -105,7 +104,7 @@ export default function SessionDetailPage() {
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { getAccessToken, user } = useAuth();
// Initial data fetch
useEffect(() => {
@@ -393,27 +392,63 @@ export default function SessionDetailPage() {
};
// Filter events by severity for the timeline
const filteredEvents = events.filter(e => severityFilters.has(e.severity));
const filteredEvents = useMemo(() =>
events.filter(e => severityFilters.has(e.severity)),
[events, severityFilters]
);
// Group filtered events by phase
const eventsByPhase = filteredEvents.reduce((acc, event) => {
const phaseName = event.phaseName || "Unknown";
if (!acc[phaseName]) {
acc[phaseName] = [];
// Group events by phase with memoization
const { eventsByPhase, orderedPhases } = useMemo(() => {
// Sort events by sequence (chronological order)
const sortedEvents = [...filteredEvents].sort((a, b) => a.sequence - b.sequence);
// Group events by phase, inserting "Unknown" events into their chronological position
const eventsByPhase = {} as Record<string, EnrollmentEvent[]>;
let currentActivePhaseName = "Start"; // Default to Start phase
for (let i = 0; i < sortedEvents.length; i++) {
const event = sortedEvents[i];
let targetPhase = event.phaseName || "Unknown";
// If event has explicit phase (not Unknown), update current active phase
if (targetPhase !== "Unknown") {
currentActivePhaseName = targetPhase;
} else {
// Unknown events go into the current active phase
targetPhase = currentActivePhaseName;
}
if (!eventsByPhase[targetPhase]) {
eventsByPhase[targetPhase] = [];
}
eventsByPhase[targetPhase].push(event);
}
acc[phaseName].push(event);
return acc;
}, {} as Record<string, EnrollmentEvent[]>);
const phaseOrder = ["PreFlight", "Network", "Identity", "MDM Enrollment", "ESP Device Setup", "App Installation", "ESP User Setup", "Complete", "Failed"];
const orderedPhases = phaseOrder.filter(phase => eventsByPhase[phase]);
const phaseOrder = ["Start", "Device Preparation", "Device Setup", "Apps (Device)", "Account Setup", "Apps (User)", "Complete", "Failed"];
const orderedPhases = phaseOrder.filter(phase => eventsByPhase[phase] && eventsByPhase[phase].length > 0);
// Initialize all phases as expanded on first load
return { eventsByPhase, orderedPhases };
}, [filteredEvents, events.length]);
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
// Auto-expand new phases as they appear (keeps existing expanded/collapsed state)
useEffect(() => {
if (orderedPhases.length > 0 && expandedPhases.size === 0) {
setExpandedPhases(new Set(orderedPhases));
}
}, [orderedPhases.length]);
setExpandedPhases(prev => {
// Add any new phases that aren't already tracked
const newExpanded = new Set(prev);
let hasChanges = false;
for (const phase of orderedPhases) {
if (!prev.has(phase)) {
newExpanded.add(phase);
hasChanges = true;
}
}
return hasChanges ? newExpanded : prev;
});
}, [orderedPhases]);
const expandAll = () => {
setExpandedPhases(new Set(orderedPhases));
@@ -424,13 +459,15 @@ export default function SessionDetailPage() {
};
const togglePhase = (phaseName: string) => {
const newExpanded = new Set(expandedPhases);
if (newExpanded.has(phaseName)) {
newExpanded.delete(phaseName);
} else {
newExpanded.add(phaseName);
}
setExpandedPhases(newExpanded);
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
if (newExpanded.has(phaseName)) {
newExpanded.delete(phaseName);
} else {
newExpanded.add(phaseName);
}
return newExpanded;
});
};
if (loading) {
@@ -459,15 +496,6 @@ export default function SessionDetailPage() {
</h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push(`/flight-tracker/${sessionId}`)}
className="px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-900 transition-colors flex items-center gap-2 text-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
Flight Tracker
</button>
{session?.status === 'Failed' && (
<button
onClick={() => router.push(`/diagnosis/${sessionId}`)}
@@ -512,13 +540,16 @@ export default function SessionDetailPage() {
</div>
)}
{/* Device Details Card (from enrollment tracker events) */}
<DeviceDetailsCard events={events} />
{/* Phase Timeline */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Enrollment Progress</h2>
<PhaseTimeline
currentPhase={session.currentPhase}
completedPhases={session.status === 'Succeeded' ? [7] : []}
completedPhases={session.status === 'Succeeded' ? [6] : []}
events={events}
sessionStatus={session.status}
/>
@@ -611,9 +642,9 @@ export default function SessionDetailPage() {
events={events.filter(e => e.eventType === "performance_snapshot")}
/>
{/* Download Progress (from download_progress events) */}
{/* Download Progress (from download_progress or app_tracking_summary events) */}
<DownloadProgress
events={events.filter(e => e.eventType === "download_progress")}
events={events.filter(e => e.eventType === "download_progress" || e.eventType === "app_tracking_summary")}
/>
{/* Timeline */}
@@ -681,6 +712,7 @@ export default function SessionDetailPage() {
events={eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
isGalacticAdmin={user?.isGalacticAdmin}
/>
))}
</div>
@@ -745,12 +777,14 @@ function PhaseSection({
phaseName,
events,
isExpanded,
onToggle
onToggle,
isGalacticAdmin
}: {
phaseName: string;
events: EnrollmentEvent[];
isExpanded: boolean;
onToggle: () => void;
isGalacticAdmin?: boolean;
}) {
return (
<div className="border-l-4 border-blue-500 pl-4">
@@ -767,7 +801,11 @@ function PhaseSection({
{isExpanded && (
<div className="space-y-3">
{events.map((event, index) => (
<EventRow key={event.eventId || `${event.sessionId}-${event.sequence}`} event={event} />
<EventRow
key={event.eventId || `${event.sessionId}-${event.sequence}`}
event={event}
isGalacticAdmin={isGalacticAdmin}
/>
))}
</div>
)}
@@ -775,8 +813,19 @@ function PhaseSection({
);
}
function EventRow({ event }: { event: EnrollmentEvent }) {
function EventRow({ event, isGalacticAdmin }: { event: EnrollmentEvent; isGalacticAdmin?: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);
const copyEventId = async () => {
try {
await navigator.clipboard.writeText(event.eventId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy EventID:', err);
}
};
return (
<div className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
@@ -793,6 +842,15 @@ function EventRow({ event }: { event: EnrollmentEvent }) {
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
{isGalacticAdmin && (
<button
onClick={copyEventId}
className="font-mono hover:text-blue-600 cursor-pointer transition-colors"
title={copied ? 'Copied!' : `Click to copy full EventId: ${event.eventId}`}
>
EventId: {event.eventId.substring(0, 8)}... {copied && '✓'}
</button>
)}
</div>
</div>
{event.data && Object.keys(event.data).length > 0 && (
@@ -821,14 +879,13 @@ function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStat
sessionStatus?: string;
}) {
const phases = [
{ id: 0, name: "PreFlight", shortName: "Pre-Flight" },
{ id: 1, name: "Network", shortName: "Network" },
{ id: 2, name: "Identity", shortName: "Identity" },
{ id: 3, name: "MDM Enrollment", shortName: "MDM" },
{ id: 4, name: "ESP Device Setup", shortName: "Device" },
{ id: 5, name: "App Installation", shortName: "Apps" },
{ id: 6, name: "ESP User Setup", shortName: "User" },
{ id: 7, name: "Complete", shortName: "Complete" },
{ id: 0, name: "Start", shortName: "Start" },
{ id: 1, name: "Device Preparation", shortName: "Device Preparation" },
{ id: 2, name: "Device Setup", shortName: "Device Setup" },
{ id: 3, name: "Apps (Device)", shortName: "Apps (Device)" },
{ id: 4, name: "Account Setup", shortName: "Account Setup" },
{ id: 5, name: "Apps (User)", shortName: "Apps (User)" },
{ id: 6, name: "Complete", shortName: "Complete" },
];
// Derive current activity for the active phase from events
@@ -850,7 +907,18 @@ function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStat
if (phaseEvents.length === 0) return null;
// Check for esp_ui_state events to show app install progress
// Check for app_tracking_summary events (new strategic events)
const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary");
if (trackingSummary?.data) {
const d = trackingSummary.data;
const completed = parseInt(d.appsCompleted ?? "0", 10);
const total = parseInt(d.totalApps ?? "0", 10);
if (total > 0) {
return `Installing apps (${completed}/${total})`;
}
}
// Check for esp_ui_state events (legacy) to show app install progress
const espState = phaseEvents.find(e => e.eventType === "esp_ui_state");
if (espState?.data) {
const d = espState.data;
@@ -865,7 +933,17 @@ function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStat
}
}
// Check for download_progress to show active download
// Check for app install events (new strategic events)
const appInstallEvt = phaseEvents.find(e =>
e.eventType === "app_download_started" || e.eventType === "app_install_started"
);
if (appInstallEvt?.data) {
const appName = appInstallEvt.data.appName ?? appInstallEvt.data.appId ?? "app";
if (appInstallEvt.eventType === "app_download_started") return `Downloading ${appName}`;
return `Installing ${appName}`;
}
// Check for download_progress to show active download (legacy)
const downloadEvt = phaseEvents.find(e => e.eventType === "download_progress");
if (downloadEvt?.data) {
const d = downloadEvt.data;
@@ -932,6 +1010,10 @@ function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStat
})();
const getPhaseStatus = (phaseId: number) => {
// Agent starts at MDM phase (3) - Pre-Flight(0)/Network(1)/Identity(2) are inferred as completed
// since the machine reached MDM enrollment
if (phaseId >= 0 && phaseId <= 2) return 'completed'; // Pre-Flight(0), Network(1), Identity(2)
// If phase is completed, show as completed (green)
if (completedPhases.includes(phaseId)) return 'completed';
@@ -1164,6 +1246,246 @@ function AnalysisResultCard({ result }: { result: RuleResult }) {
);
}
function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
const [expanded, setExpanded] = useState(false);
const [showIpv6, setShowIpv6] = useState<Record<number, boolean>>({});
// Extract device detail events
const getEventData = (eventType: string): Record<string, any> | null => {
const evt = events.find(e => e.eventType === eventType);
return evt?.data ?? null;
};
// Helper to check if IP is IPv6
const isIpv6 = (ip: string): boolean => {
// IPv6 contains colons and has multiple segments
// IPv4 is like: 192.168.1.1
// IPv6 is like: fe80::1234:5678:abcd:ef01 or 2001:db8::1
if (!ip || typeof ip !== 'string') return false;
// If it contains a colon and doesn't look like IPv4, it's IPv6
// Simple check: IPv6 has at least 2 colons (even compressed form)
const colonCount = (ip.match(/:/g) || []).length;
return colonCount >= 2;
};
// Helper to split IPs into IPv4 and IPv6
const splitIpAddresses = (ipAddresses: string | string[]): { ipv4: string[]; ipv6: string[] } => {
// Handle both array and comma-separated string
let ips: string[];
if (Array.isArray(ipAddresses)) {
ips = ipAddresses;
} else if (typeof ipAddresses === 'string') {
// Split by comma if it's a comma-separated string
ips = ipAddresses.split(',').map(ip => ip.trim()).filter(ip => ip.length > 0);
} else {
ips = [];
}
const ipv4: string[] = [];
const ipv6: string[] = [];
for (const ip of ips) {
if (typeof ip === 'string' && ip.trim()) {
if (isIpv6(ip)) {
ipv6.push(ip);
} else {
ipv4.push(ip);
}
}
}
return { ipv4, ipv6 };
};
const agentStarted = getEventData("agent_started");
const networkAdapters = getEventData("network_adapters");
const dnsConfig = getEventData("dns_configuration");
const proxyConfig = getEventData("proxy_configuration");
const autopilotProfile = getEventData("autopilot_profile");
const aadJoinStatus = getEventData("aad_join_status");
const imeVersion = getEventData("ime_agent_version");
const bitLockerStatus = getEventData("bitlocker_status");
const secureBootStatus = getEventData("secureboot_status");
// Check if we have any device detail events at all
const hasData = agentStarted || networkAdapters || dnsConfig || proxyConfig || autopilotProfile ||
aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus;
if (!hasData) return null;
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<h2 className="text-xl font-semibold text-gray-900">Device Details</h2>
</div>
<span className="text-gray-400">{expanded ? '▼' : '▶'}</span>
</button>
{expanded && (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Boot Time */}
{agentStarted?.bootTime && (
<DetailSection title="System Boot">
<DetailRow label="Boot Time" value={new Date(agentStarted.bootTime).toLocaleString()} />
{agentStarted.agentVersion && <DetailRow label="Agent Version" value={agentStarted.agentVersion} />}
</DetailSection>
)}
{/* Network */}
{networkAdapters && (
<DetailSection title="Network">
{networkAdapters.adapters ? (
(networkAdapters.adapters as any[]).map((adapter: any, i: number) => {
const { ipv4, ipv6 } = adapter.ipAddresses ? splitIpAddresses(adapter.ipAddresses) : { ipv4: [], ipv6: [] };
const hasIpv6 = ipv6.length > 0;
const isIpv6Shown = showIpv6[i] ?? false;
return (
<div key={i} className="mb-2 last:mb-0">
<div className="text-sm font-medium text-gray-700">{adapter.description || adapter.name || `Adapter ${i + 1}`}</div>
{/* DHCP - first */}
{adapter.dhcpEnabled !== undefined && <DetailRow label="DHCP" value={adapter.dhcpEnabled ? "Enabled" : "Disabled"} />}
{/* MAC - second */}
{adapter.macAddress && <DetailRow label="MAC" value={adapter.macAddress} />}
{/* IPv4 - third */}
{ipv4.length > 0 && <DetailRow label="IPv4" value={ipv4.join(", ")} />}
{/* IPv6 - fourth (collapsible) */}
{hasIpv6 && (
<div className="mt-1">
<button
onClick={() => setShowIpv6(prev => ({ ...prev, [i]: !isIpv6Shown }))}
className="text-xs text-blue-600 hover:text-blue-800 flex items-center space-x-1"
>
<span>{isIpv6Shown ? '▼' : '▶'}</span>
<span>IPv6 ({ipv6.length})</span>
</button>
{isIpv6Shown && (
<div className="mt-1 pl-4 text-xs text-gray-600 space-y-0.5">
{ipv6.map((ip, idx) => (
<div key={idx} className="font-mono">{ip}</div>
))}
</div>
)}
</div>
)}
</div>
);
})
) : (
<DetailRow label="Count" value={networkAdapters.adapterCount?.toString() ?? "N/A"} />
)}
</DetailSection>
)}
{/* DNS */}
{dnsConfig && (
<DetailSection title="DNS">
{dnsConfig.dnsEntries && Array.isArray(dnsConfig.dnsEntries) && dnsConfig.dnsEntries.length > 0 ? (
(dnsConfig.dnsEntries as any[]).map((entry: any, i: number) => (
<div key={i} className="mb-1 last:mb-0">
<DetailRow label={entry.adapter || `Adapter ${i + 1}`} value={entry.servers || "N/A"} />
</div>
))
) : (
<div className="text-sm text-gray-500">No DNS data</div>
)}
</DetailSection>
)}
{/* Proxy */}
{proxyConfig && (
<DetailSection title="Proxy">
<DetailRow label="Type" value={proxyConfig.proxyType ?? proxyConfig.type ?? "Direct"} />
{proxyConfig.proxyServer && <DetailRow label="Server" value={proxyConfig.proxyServer} />}
{proxyConfig.autoConfigUrl && <DetailRow label="PAC URL" value={proxyConfig.autoConfigUrl} />}
{proxyConfig.winHttpProxy && <DetailRow label="WinHTTP" value={proxyConfig.winHttpProxy} />}
</DetailSection>
)}
{/* Autopilot Profile */}
{autopilotProfile && (
<DetailSection title="Autopilot Profile">
{autopilotProfile.tenantDomain && <DetailRow label="Domain" value={autopilotProfile.tenantDomain} />}
{autopilotProfile.deploymentProfileName && <DetailRow label="Profile" value={autopilotProfile.deploymentProfileName} />}
{autopilotProfile.cloudAssignedTenantId && <DetailRow label="Tenant ID" value={autopilotProfile.cloudAssignedTenantId} />}
{autopilotProfile.oobeConfig && <DetailRow label="OOBE Config" value={autopilotProfile.oobeConfig} />}
</DetailSection>
)}
{/* Identity */}
{aadJoinStatus && (
<DetailSection title="Identity">
<DetailRow label="Join Type" value={aadJoinStatus.joinType ?? "Unknown"} />
{aadJoinStatus.tenantId && <DetailRow label="Tenant ID" value={aadJoinStatus.tenantId} />}
</DetailSection>
)}
{/* IME */}
{imeVersion && (
<DetailSection title="IME Agent">
<DetailRow label="Version" value={imeVersion.version ?? imeVersion.agentVersion ?? "Unknown"} />
</DetailSection>
)}
{/* Security */}
{(bitLockerStatus || secureBootStatus) && (
<DetailSection title="Security">
{secureBootStatus && (
<DetailRow label="SecureBoot" value={secureBootStatus.uefiSecureBootEnabled ? "Enabled" : "Disabled"} />
)}
{bitLockerStatus && (
<>
<DetailRow label="BitLocker" value={bitLockerStatus.systemDriveProtected ? "Protected" : "Not Protected"} />
{bitLockerStatus.volumes && Array.isArray(bitLockerStatus.volumes) && bitLockerStatus.volumes.length > 0 && (
<div className="mt-1 text-xs text-gray-500">
{(bitLockerStatus.volumes as any[]).map((vol: any, i: number) => (
<div key={i}>
{vol.driveLetter}: {vol.protectionStatus === "1" ? "Protected" : "Not Protected"}
{vol.encryptionMethod && ` (Method: ${vol.encryptionMethod})`}
</div>
))}
</div>
)}
</>
)}
</DetailSection>
)}
</div>
)}
</div>
);
}
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-gray-700 mb-2 border-b border-gray-100 pb-1">{title}</h3>
<div>{children}</div>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-xs py-0.5">
<span className="text-gray-500">{label}</span>
<span className="text-gray-900 font-mono ml-2 text-right break-all" title={value}>{value}</span>
</div>
);
}
function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
@@ -20,11 +20,6 @@ interface TenantConfiguration {
// Agent collector settings
enablePerformanceCollector: boolean;
performanceCollectorIntervalSeconds: number;
enableDownloadProgressCollector: boolean;
downloadProgressCollectorIntervalSeconds: number;
enableCertValidationCollector: boolean;
enableEspUiStateCollector: boolean;
espUiStateCollectorIntervalSeconds: number;
}
interface TenantAdmin {
@@ -63,11 +58,6 @@ export default function SettingsPage() {
// Collector settings state
const [enablePerformanceCollector, setEnablePerformanceCollector] = useState(false);
const [performanceCollectorInterval, setPerformanceCollectorInterval] = useState(60);
const [enableDownloadProgressCollector, setEnableDownloadProgressCollector] = useState(false);
const [downloadProgressCollectorInterval, setDownloadProgressCollectorInterval] = useState(15);
const [enableCertValidationCollector, setEnableCertValidationCollector] = useState(false);
const [enableEspUiStateCollector, setEnableEspUiStateCollector] = useState(false);
const [espUiStateCollectorInterval, setEspUiStateCollectorInterval] = useState(30);
// Fetch configuration
@@ -106,11 +96,6 @@ export default function SettingsPage() {
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
setEnablePerformanceCollector(data.enablePerformanceCollector ?? false);
setPerformanceCollectorInterval(data.performanceCollectorIntervalSeconds ?? 60);
setEnableDownloadProgressCollector(data.enableDownloadProgressCollector ?? false);
setDownloadProgressCollectorInterval(data.downloadProgressCollectorIntervalSeconds ?? 15);
setEnableCertValidationCollector(data.enableCertValidationCollector ?? false);
setEnableEspUiStateCollector(data.enableEspUiStateCollector ?? false);
setEspUiStateCollectorInterval(data.espUiStateCollectorIntervalSeconds ?? 30);
} catch (err) {
console.error("Error fetching configuration:", err);
setError(err instanceof Error ? err.message : "Failed to load configuration");
@@ -176,11 +161,6 @@ export default function SettingsPage() {
sessionTimeoutHours,
enablePerformanceCollector,
performanceCollectorIntervalSeconds: performanceCollectorInterval,
enableDownloadProgressCollector,
downloadProgressCollectorIntervalSeconds: downloadProgressCollectorInterval,
enableCertValidationCollector,
enableEspUiStateCollector,
espUiStateCollectorIntervalSeconds: espUiStateCollectorInterval,
};
const token = await getAccessToken();
@@ -225,11 +205,6 @@ export default function SettingsPage() {
setSessionTimeoutHours(config.sessionTimeoutHours ?? 5);
setEnablePerformanceCollector(config.enablePerformanceCollector ?? false);
setPerformanceCollectorInterval(config.performanceCollectorIntervalSeconds ?? 60);
setEnableDownloadProgressCollector(config.enableDownloadProgressCollector ?? false);
setDownloadProgressCollectorInterval(config.downloadProgressCollectorIntervalSeconds ?? 15);
setEnableCertValidationCollector(config.enableCertValidationCollector ?? false);
setEnableEspUiStateCollector(config.enableEspUiStateCollector ?? false);
setEspUiStateCollectorInterval(config.espUiStateCollectorIntervalSeconds ?? 30);
setSuccessMessage(null);
setError(null);
};
@@ -770,92 +745,13 @@ export default function SettingsPage() {
</button>
</div>
{/* Download Progress Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Download Progress Collector</p>
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-100 text-orange-700">High Traffic</span>
</div>
<p className="text-sm text-gray-500 mt-1">Tracks IME content download progress and speed</p>
{enableDownloadProgressCollector && (
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min="5"
max="120"
value={downloadProgressCollectorInterval}
onChange={(e) => setDownloadProgressCollectorInterval(parseInt(e.target.value) || 15)}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
)}
</div>
<button
onClick={() => setEnableDownloadProgressCollector(!enableDownloadProgressCollector)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enableDownloadProgressCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableDownloadProgressCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Certificate Validation Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Certificate Validation Collector</p>
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">Low Traffic</span>
</div>
<p className="text-sm text-gray-500 mt-1">Validates enrollment endpoint certificates, detects SSL inspection</p>
</div>
<button
onClick={() => setEnableCertValidationCollector(!enableCertValidationCollector)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enableCertValidationCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableCertValidationCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* ESP UI State Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">ESP UI State Collector</p>
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Medium Traffic</span>
</div>
<p className="text-sm text-gray-500 mt-1">Tracks ESP blocking apps progress and status text</p>
{enableEspUiStateCollector && (
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min="10"
max="120"
value={espUiStateCollectorInterval}
onChange={(e) => setEspUiStateCollectorInterval(parseInt(e.target.value) || 30)}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
)}
</div>
<button
onClick={() => setEnableEspUiStateCollector(!enableEspUiStateCollector)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enableEspUiStateCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableEspUiStateCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-blue-800">
Changes take effect on the next agent config refresh (default: every 5 minutes). Core collectors (EventLog, Registry, Phase Detector, Hello Detector) are always active.
Changes take effect on the next agent config refresh (default: every 5 minutes). Core collectors (Hello Detector) and enrollment tracking are always active.
</p>
</div>
</div>
@@ -18,6 +18,7 @@ interface DownloadItem {
downloadRateBps: number;
lastUpdated: string;
isComplete: boolean;
eventData?: Record<string, any>;
}
function formatBytes(bytes: number): string {
@@ -55,25 +56,45 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
if (!d) continue;
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? "Unknown App";
// Skip unknown apps
if (appName === "Unknown App") continue;
const bytesDownloaded = parseInt(d.bytes_downloaded ?? d.bytesDownloaded ?? "0", 10);
const bytesTotal = parseInt(d.bytes_total ?? d.bytesTotal ?? "0", 10);
const downloadRateBps = parseFloat(d.download_rate_bps ?? d.downloadRateBps ?? "0");
const status = d.status ?? "";
// Determine if complete: explicit status or bytes comparison
const isComplete = status === "completed" || status === "failed" || (bytesTotal > 0 && bytesDownloaded >= bytesTotal);
// Skip if bytesTotal is too small (< 1 KB) AND not explicitly completed/failed
if (bytesTotal > 0 && bytesTotal < 1024 && status !== "completed" && status !== "failed") {
continue;
}
// Skip if no download activity yet (0 bytes downloaded, not completed/failed)
// This makes downloads appear dynamically as they start
if (bytesDownloaded === 0 && bytesTotal === 0 && status !== "completed" && status !== "failed") {
continue;
}
downloadMap.set(appName, {
appName,
bytesDownloaded: isNaN(bytesDownloaded) ? 0 : bytesDownloaded,
bytesTotal: isNaN(bytesTotal) ? 0 : bytesTotal,
downloadRateBps: isNaN(downloadRateBps) ? 0 : downloadRateBps,
lastUpdated: evt.timestamp,
isComplete: status === "completed" || (bytesTotal > 0 && bytesDownloaded >= bytesTotal),
isComplete,
eventData: d,
});
}
return Array.from(downloadMap.values()).sort((a, b) => {
// In-progress first, then completed
if (a.isComplete !== b.isComplete) return a.isComplete ? 1 : -1;
return new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
// Chronological order: oldest first (first download on top, newest at bottom)
return new Date(a.lastUpdated).getTime() - new Date(b.lastUpdated).getTime();
});
}, [events]);
@@ -119,9 +140,18 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
: 0;
const bytesRemaining = dl.bytesTotal > 0 ? dl.bytesTotal - dl.bytesDownloaded : 0;
return (
return <DownloadItem key={dl.appName} download={dl} progressPercent={progressPercent} bytesRemaining={bytesRemaining} />;
})}
</div>}
</div>
);
}
function DownloadItem({ download: dl, progressPercent, bytesRemaining }: { download: DownloadItem; progressPercent: number; bytesRemaining: number }) {
const [showDetails, setShowDetails] = useState(false);
return (
<div
key={dl.appName}
className={`rounded-lg p-3 ${dl.isComplete ? "bg-green-50 border border-green-200" : "bg-gray-50 border border-gray-200"}`}
>
<div className="flex items-center justify-between mb-1">
@@ -144,6 +174,14 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
{!dl.isComplete && dl.bytesTotal > 0 && (
<span>ETA: {formatEta(bytesRemaining, dl.downloadRateBps)}</span>
)}
{dl.eventData && Object.keys(dl.eventData).length > 0 && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showDetails ? 'Hide' : 'Details'}
</button>
)}
</div>
</div>
@@ -172,10 +210,13 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
{dl.downloadRateBps > 0 && ` at ${formatSpeed(dl.downloadRateBps)}`}
</div>
)}
{/* Event details (expandable) */}
{showDetails && dl.eventData && (
<div className="mt-3 p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(dl.eventData, null, 2)}</pre>
</div>
)}
</div>
);
})}
</div>}
</div>
);
}
File diff suppressed because one or more lines are too long