diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1
index 88cd7f4e..04ebb242 100644
--- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1
+++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1
@@ -49,7 +49,6 @@ param(
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentConfigPath = "$AgentBasePath\Config"
-$AgentRulesPath = "$AgentBasePath\Rules"
$AgentSpoolPath = "$AgentBasePath\Spool"
$AgentLogsPath = "$AgentBasePath\Logs"
$TaskName = "AutopilotMonitor-Agent"
@@ -59,7 +58,6 @@ $LogFile = "$AgentLogsPath\Bootstrap.log"
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentConfigPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
-New-Item -Path $AgentRulesPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentSpoolPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogsPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs
index e5a77327..8e450b47 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs
@@ -129,6 +129,14 @@ namespace AutopilotMonitor.Agent.Core.Configuration
///
public double SimulationSpeedFactor { get; set; } = 50;
+ ///
+ /// Wait time in seconds after ESP exit before marking Hello as skipped.
+ /// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
+ /// Default: 30 seconds (reasonable for systems under load).
+ /// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
+ ///
+ public int HelloWaitTimeoutSeconds { get; set; } = 30;
+
///
/// Validates the configuration
///
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs
index 51f5fbe6..e294997b 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs
@@ -12,12 +12,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Monitors the "Microsoft-Windows-User Device Registration/Admin" event log channel
/// and checks registry for WHfB policy configuration.
///
- /// Key Event IDs:
+ /// Key Event IDs (User Device Registration/Admin):
/// 358 - Prerequisites passed, provisioning will be launched
- /// 360 - Prerequisites failed, provisioning will NOT be launched
+ /// 360 - Prerequisites failed, provisioning will NOT be launched (SNAPSHOT ONLY - not terminal)
/// 362 - Provisioning blocked
/// 300 - NGC key registered successfully (Hello provisioned)
/// 301 - NGC key registration failed
+ ///
+ /// Key Event IDs (Microsoft-Windows-Shell-Core/Operational):
+ /// 62404 - CloudExperienceHost Web App Activity Started (CXID: 'AADHello' or 'NGC' - Hello wizard started)
+ /// 62407 - CloudExperienceHost Web App Event 2 (CommercialOOBE_ESPProgress_Page_Exiting - ESP exit)
///
public class HelloDetector : IDisposable
{
@@ -26,24 +30,38 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private readonly string _tenantId;
private readonly Action _onEventCollected;
private System.Diagnostics.Eventing.Reader.EventLogWatcher _watcher;
+ private System.Diagnostics.Eventing.Reader.EventLogWatcher _shellCoreWatcher;
private System.Threading.Timer _policyCheckTimer;
+ private System.Threading.Timer _helloWaitTimer;
private bool _isPolicyConfigured = false;
private bool _isHelloCompleted = false;
+ private bool _espExitDetected = false;
+ private bool _helloWizardStarted = false;
private readonly object _stateLock = new object();
+ private readonly int _helloWaitTimeoutSeconds;
+
///
- /// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or blocked)
+ /// Callback invoked when Hello provisioning completes (successfully or failed)
+ /// Based on events 300/301 only, NOT on event 360 (which is just a snapshot)
///
public event EventHandler HelloCompleted;
+ ///
+ /// Callback invoked when ESP exit or Hello wizard start is detected
+ /// Triggers transition to FinalizingSetup phase
+ ///
+ public event EventHandler FinalizingSetupPhaseTriggered;
+
private const string EventLogChannel = "Microsoft-Windows-User Device Registration/Admin";
+ private const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
// WHfB policy registry paths
private const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork";
private const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork";
- // Tracked event IDs
+ // Tracked event IDs (User Device Registration)
private const int EventId_NgcKeyRegistered = 300;
private const int EventId_NgcKeyRegistrationFailed = 301;
private const int EventId_ProvisioningWillLaunch = 358;
@@ -51,6 +69,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private const int EventId_ProvisioningBlocked = 362;
private const int EventId_PinStatus = 376;
+ // Tracked event IDs (Shell-Core/Operational)
+ private const int EventId_ShellCore_WebAppStarted = 62404;
+ private const int EventId_ShellCore_WebAppEvent = 62407;
+
private static readonly HashSet TrackedEventIds = new HashSet
{
EventId_NgcKeyRegistered,
@@ -61,12 +83,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
EventId_PinStatus
};
- public HelloDetector(string sessionId, string tenantId, Action onEventCollected, AgentLogger logger)
+ private static readonly HashSet TrackedShellCoreEventIds = new HashSet
+ {
+ EventId_ShellCore_WebAppStarted,
+ EventId_ShellCore_WebAppEvent
+ };
+
+ public HelloDetector(string sessionId, string tenantId, Action onEventCollected, AgentLogger logger, int helloWaitTimeoutSeconds = 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));
+ _helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
}
///
@@ -92,16 +121,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
// Check if WHfB policy is configured initially
CheckHelloPolicy();
- // Start periodic policy check (every 30 seconds) to detect policy arriving later via MDM
+ // Start periodic policy check to detect policy arriving later via MDM
_policyCheckTimer = new System.Threading.Timer(
_ => CheckHelloPolicy(),
null,
- TimeSpan.FromSeconds(30),
- TimeSpan.FromSeconds(30)
+ TimeSpan.FromSeconds(30), // Initial delay before first check (to allow policies to apply during enrollment)
+ TimeSpan.FromSeconds(30) // Subsequent checks every 15 seconds
);
// Subscribe to User Device Registration event log
StartEventLogWatcher();
+
+ // Subscribe to Shell-Core/Operational event log for ESP exit and Hello wizard detection
+ StartShellCoreEventLogWatcher();
}
public void Stop()
@@ -122,6 +154,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
+ // Stop Hello wait timer
+ if (_helloWaitTimer != null)
+ {
+ try
+ {
+ _helloWaitTimer.Dispose();
+ _helloWaitTimer = null;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error stopping Hello wait timer", ex);
+ }
+ }
+
+ // Stop User Device Registration watcher
if (_watcher != null)
{
try
@@ -136,6 +183,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Error("Error stopping Hello detector watcher", ex);
}
}
+
+ // Stop Shell-Core/Operational watcher
+ if (_shellCoreWatcher != null)
+ {
+ try
+ {
+ _shellCoreWatcher.Enabled = false;
+ _shellCoreWatcher.EventRecordWritten -= OnShellCoreEventRecordWritten;
+ _shellCoreWatcher.Dispose();
+ _shellCoreWatcher = null;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error stopping Shell-Core event watcher", ex);
+ }
+ }
}
private void CheckHelloPolicy()
@@ -333,12 +396,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
case EventId_ProvisioningWillNotLaunch: // 360
eventType = "hello_provisioning_skipped";
severity = EventSeverity.Warning;
- message = "Windows Hello for Business provisioning skipped - prerequisites not met";
- // Mark Hello as completed (terminal event - skipped counts as completed)
- lock (_stateLock) { _isHelloCompleted = true; }
- _logger.Info("Windows Hello provisioning COMPLETED (skipped)");
- // Trigger HelloCompleted event for EnrollmentTracker
- try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
+ message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not terminal)";
+ // DO NOT mark Hello as completed - event 360 is just a snapshot and can change
+ // DO NOT trigger HelloCompleted event
+ // Wait for actual terminal events: 300 (success), 301 (failed), or ESP exit + timeout
+ _logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
break;
case EventId_ProvisioningBlocked: // 362
@@ -370,7 +432,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
EventType = eventType,
Severity = severity,
Source = "HelloDetector",
- Phase = EnrollmentPhase.FinalizingSetup,
+ Phase = EnrollmentPhase.Unknown, // Phase will be determined by EnrollmentTracker based on context
Message = message,
Data = new Dictionary
{
@@ -388,6 +450,219 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
+ private void StartShellCoreEventLogWatcher()
+ {
+ try
+ {
+ // Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log
+ var query = new EventLogQuery(
+ ShellCoreEventLogChannel,
+ PathType.LogName,
+ "*[System[TimeCreated[timediff(@SystemTime) <= 1000]]]"
+ );
+
+ _shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
+ _shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten;
+ _shellCoreWatcher.Enabled = true;
+
+ _logger.Info($"Started watching: {ShellCoreEventLogChannel}");
+ }
+ catch (EventLogNotFoundException)
+ {
+ _logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Failed to start Shell-Core event log watcher", ex);
+ }
+ }
+
+ private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
+ {
+ if (e.EventRecord == null)
+ return;
+
+ try
+ {
+ var record = e.EventRecord;
+ var eventId = record.Id;
+
+ if (!TrackedShellCoreEventIds.Contains(eventId))
+ return;
+
+ var description = record.FormatDescription() ?? $"Event ID {eventId}";
+
+ string eventType;
+ EventSeverity severity = EventSeverity.Info;
+ string message;
+ bool triggerFinalizingSetup = false;
+ string finalizingSetupReason = null;
+
+ switch (eventId)
+ {
+ case EventId_ShellCore_WebAppStarted: // 62404
+ // Check if this is AADHello or NGC (Hello wizard started)
+ if (description.Contains("AADHello") || description.Contains("'NGC'"))
+ {
+ eventType = "hello_wizard_started";
+ message = "Windows Hello wizard started (CloudExperienceHost)";
+ triggerFinalizingSetup = true;
+ finalizingSetupReason = "hello_wizard_started";
+
+ lock (_stateLock)
+ {
+ _helloWizardStarted = true;
+
+ // Stop the hello wait timer if running (Hello wizard appeared within timeout)
+ if (_helloWaitTimer != null)
+ {
+ _helloWaitTimer.Dispose();
+ _helloWaitTimer = null;
+ _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
+ }
+ }
+
+ _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
+ }
+ else
+ {
+ // Not Hello-related, ignore
+ return;
+ }
+ break;
+
+ case EventId_ShellCore_WebAppEvent: // 62407
+ // Check if this is ESP exit event
+ // Use robust pattern: OOBE_ESP*Exit* instead of full string CommercialOOBE_ESPProgress_Page_Exiting
+ if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exit", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
+ {
+ eventType = "esp_exiting";
+ message = "ESP (Enrollment Status Page) is exiting - enrollment nearing completion";
+ triggerFinalizingSetup = true;
+ finalizingSetupReason = "esp_exiting";
+
+ lock (_stateLock)
+ {
+ _espExitDetected = true;
+
+ // Start timer to wait for Hello wizard to start
+ _logger.Info($"ESP exit detected - starting {_helloWaitTimeoutSeconds}s wait timer for Hello wizard");
+ _helloWaitTimer = new System.Threading.Timer(
+ OnHelloWaitTimeout,
+ null,
+ TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1) // One-shot timer
+ );
+ }
+
+ _logger.Info("ESP exit detected - detected via Shell-Core event 62407");
+ }
+ else
+ {
+ // Not ESP exit, ignore
+ return;
+ }
+ break;
+
+ default:
+ return;
+ }
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = record.TimeCreated ?? DateTime.UtcNow,
+ EventType = eventType,
+ Severity = severity,
+ Source = "HelloDetector",
+ Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase
+ Message = message,
+ Data = new Dictionary
+ {
+ { "windowsEventId", eventId },
+ { "providerName", record.ProviderName ?? "" },
+ { "description", description },
+ { "eventLogChannel", ShellCoreEventLogChannel }
+ }
+ });
+
+ _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
+
+ // Trigger FinalizingSetup phase transition
+ if (triggerFinalizingSetup)
+ {
+ try
+ {
+ FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason);
+ }
+ catch { }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error processing Shell-Core event record", ex);
+ }
+ }
+
+ ///
+ /// Called when the Hello wait timer expires after ESP exit.
+ /// If Hello wizard hasn't started by now, we assume it's not configured or was skipped.
+ /// Mark Hello as completed so enrollment can proceed.
+ ///
+ private void OnHelloWaitTimeout(object state)
+ {
+ lock (_stateLock)
+ {
+ if (_helloWizardStarted)
+ {
+ // Hello wizard already started, this timeout is obsolete
+ _logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
+ return;
+ }
+
+ if (_isHelloCompleted)
+ {
+ // Hello already completed via events 300/301 - timeout is obsolete
+ _logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
+ return;
+ }
+
+ // Timeout expired without Hello wizard starting
+ _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
+ _logger.Info("Assuming Hello is not configured or was skipped - marking as completed");
+
+ _isHelloCompleted = true;
+
+ // Emit event for tracking
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "hello_wait_timeout",
+ Severity = EventSeverity.Info,
+ Source = "HelloDetector",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
+ Data = new Dictionary
+ {
+ { "timeoutSeconds", _helloWaitTimeoutSeconds },
+ { "espExitDetected", _espExitDetected }
+ }
+ });
+
+ // Trigger HelloCompleted so enrollment can proceed
+ try
+ {
+ HelloCompleted?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error invoking HelloCompleted from timeout", ex);
+ }
+ }
+ }
+
public void Dispose()
{
Stop();
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
index 32aeb973..b80e1889 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
@@ -172,7 +172,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
- _logger
+ _logger,
+ _configuration.HelloWaitTimeoutSeconds
);
_helloDetector.Start();
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs
index 9a99f912..eec0de10 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs
@@ -105,6 +105,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
if (_helloDetector != null)
{
_helloDetector.HelloCompleted += OnHelloCompleted;
+ _helloDetector.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
}
}
@@ -781,33 +782,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
// Emit final summary
EmitAppTrackingSummary();
+ // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
+ // (ESP exit or Hello wizard start) for more robust detection.
+ // We no longer automatically transition here when apps complete.
if (_lastEspPhase != null)
{
_logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'");
-
- // When all user apps complete during AccountSetup, transition to FinalizingSetup.
- // This signals the progress portal that app installation is done and
- // we're now waiting for final ESP steps (e.g. Windows Hello provisioning).
- if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- _logger.Info("EnrollmentTracker: User apps completed - transitioning to FinalizingSetup phase");
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "ESP phase: FinalizingSetup (all user apps completed, waiting for final steps)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "previousPhase", "AccountSetup" }
- }
- });
- }
+ _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
}
}
@@ -919,6 +900,33 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
}
}
+ ///
+ /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events)
+ /// Triggers transition to FinalizingSetup phase
+ ///
+ private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
+ {
+ _logger.Info($"EnrollmentTracker: FinalizingSetup phase triggered - reason: {reason}");
+
+ // Emit phase change event to FinalizingSetup
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = $"ESP phase: FinalizingSetup (triggered by {reason})",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", reason }
+ }
+ });
+ }
+
private void SummaryTimerCallback(object state)
{
if (_imeLogTracker?.PackageStates?.CountAll > 0)
@@ -950,10 +958,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
Stop();
- // Unsubscribe from HelloDetector event
+ // Unsubscribe from HelloDetector events
if (_helloDetector != null)
{
_helloDetector.HelloCompleted -= OnHelloCompleted;
+ _helloDetector.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
}
_summaryTimer?.Dispose();
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs
index e64bedff..d8a670ce 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs
@@ -205,7 +205,8 @@ namespace AutopilotMonitor.Functions.Functions
await _storageService.IncrementSessionEventCountAsync(
request.TenantId,
request.SessionId,
- processedCount
+ processedCount,
+ earliestEventTimestamp
);
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
index d8dce317..09799c6e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
@@ -434,7 +434,7 @@ namespace AutopilotMonitor.Functions.Services
/// Increments the session event count without touching status or phase fields.
/// Uses Merge mode to safely handle concurrent updates.
///
- public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment)
+ public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
@@ -454,6 +454,17 @@ namespace AutopilotMonitor.Functions.Services
{
["EventCount"] = currentCount + increment
};
+
+ // Update StartedAt if an earlier event timestamp is provided
+ if (earliestEventTimestamp.HasValue)
+ {
+ var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
+ if (earliestEventTimestamp.Value < currentStartedAt)
+ {
+ update["StartedAt"] = earliestEventTimestamp.Value;
+ }
+ }
+
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, TableUpdateMode.Merge);
return;
}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx
index 04ea3024..3c6b9b6a 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx
@@ -1264,8 +1264,11 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
// Extract device detail events
const getEventData = (eventType: string): Record | null => {
- const evt = events.find(e => e.eventType === eventType);
- return evt?.data ?? null;
+ // Find the NEWEST event of this type (last occurrence) to get the most recent state
+ const matchingEvents = events.filter(e => e.eventType === eventType);
+ if (matchingEvents.length === 0) return null;
+ const latestEvent = matchingEvents[matchingEvents.length - 1];
+ return latestEvent?.data ?? null;
};
// Helper to check if IP is IPv6