mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
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:
+1
-1
@@ -16,7 +16,7 @@ bld/
|
||||
|
||||
# Visual Studio cache/options
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-azuretools.vscode-azurefunctions",
|
||||
"ms-dotnettools.csharp"
|
||||
]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Attach to .NET Functions",
|
||||
"type": "coreclr",
|
||||
"request": "attach",
|
||||
"processId": "${command:azureFunctions.pickProcess}"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+8
@@ -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)"
|
||||
}
|
||||
Vendored
+81
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-3
@@ -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
|
||||
+3
-3
@@ -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>
|
||||
{
|
||||
+2
-1
@@ -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",
|
||||
+28
-3
@@ -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>
|
||||
+65
-126
@@ -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
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file="">
|
||||
/// </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
|
||||
{
|
||||
|
||||
@@ -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., (?<id>...)) 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 (?<named>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., (?<id>...)) 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
Reference in New Issue
Block a user