From ae15d798fc7e7a5757ea53b8d128ea756559e5e1 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 16 Feb 2026 19:12:02 +0100 Subject: [PATCH] fix reboot, metrics, teams notification --- .../Monitoring/Core/MonitoringService.cs | 27 ++++++++++++++++--- .../Functions/IngestEventsFunction.cs | 14 ++++++---- .../Services/TableStorageService.cs | 15 ++++++++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 76b86128..838d3b3d 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -556,8 +556,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } }); - // Only trigger self-destruct if configured - if (_configuration.SelfDestructOnComplete) + // Trigger self-destruct or reboot if configured + if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete) { Task.Run(() => HandleEnrollmentComplete()); return; // Don't continue with normal event processing @@ -752,7 +752,28 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } else if (_configuration.RebootOnComplete) { - _logger.Info("RebootOnComplete enabled - initiating reboot"); + _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s"); + + // Emit reboot event into the timeline before shutdown + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + Timestamp = DateTime.UtcNow, + EventType = "reboot_triggered", + Severity = EventSeverity.Info, + Source = "MonitoringService", + Phase = EnrollmentPhase.Complete, + Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds", + Data = new Dictionary + { + { "rebootDelaySeconds", _configuration.RebootDelaySeconds } + } + }); + + // Final upload to ensure the reboot event is sent + await UploadEventsAsync(); + var psi = new ProcessStartInfo { FileName = "shutdown.exe", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 750a903a..d48c7900 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -136,10 +136,13 @@ namespace AutopilotMonitor.Functions.Functions } // Update session status based on events + // statusTransitioned = true only when this is the FIRST time the session reaches Succeeded/Failed + // (UpdateSessionStatusAsync returns false if already in a terminal state — idempotency guard) + bool statusTransitioned = false; if (completionEvent != null) { // Enrollment completed successfully - use event timestamp for accurate duration - await _storageService.UpdateSessionStatusAsync( + statusTransitioned = await _storageService.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Succeeded, @@ -147,7 +150,7 @@ namespace AutopilotMonitor.Functions.Functions completedAt: completionEvent.Timestamp, earliestEventTimestamp: earliestEventTimestamp ); - _logger.LogInformation($"{sessionPrefix} Status: Succeeded"); + _logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned); } else if (failureEvent != null) { @@ -156,7 +159,7 @@ namespace AutopilotMonitor.Functions.Functions ? $"{failureEvent.Message} ({failureEvent.Data["errorCode"]})" : failureEvent.Message; - await _storageService.UpdateSessionStatusAsync( + statusTransitioned = await _storageService.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Failed, @@ -165,7 +168,7 @@ namespace AutopilotMonitor.Functions.Functions completedAt: failureEvent.Timestamp, earliestEventTimestamp: earliestEventTimestamp ); - _logger.LogWarning($"{sessionPrefix} Status: Failed - {failureReason}"); + _logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned); } else if (lastPhaseChangeEvent != null) { @@ -228,7 +231,8 @@ namespace AutopilotMonitor.Functions.Functions var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); // Send Teams notification on enrollment completion (fire-and-forget, non-fatal) - if (completionEvent != null || failureEvent != null) + // Only send when statusTransitioned=true to prevent duplicates on retry/double-upload + if (statusTransitioned && (completionEvent != null || failureEvent != null)) { var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl)) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index 7de545d2..1720cfbc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -343,7 +343,8 @@ namespace AutopilotMonitor.Functions.Services var sessions = new List(); // Query all sessions without tenant filter - var query = tableClient.QueryAsync(maxPerPage: maxResults); + // maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic + var query = tableClient.QueryAsync(maxPerPage: Math.Min(maxResults, 1000)); await foreach (var entity in query) { @@ -404,6 +405,18 @@ namespace AutopilotMonitor.Functions.Services var entity = await tableClient.GetEntityAsync(tenantId, sessionId); var session = entity.Value; + // Idempotency: if the session is already in a terminal state (Succeeded/Failed), + // do not overwrite it with another terminal state to prevent duplicate notifications. + if (status == SessionStatus.Succeeded || status == SessionStatus.Failed) + { + var existingStatus = session.GetString("Status"); + if (existingStatus == SessionStatus.Succeeded.ToString() || existingStatus == SessionStatus.Failed.ToString()) + { + _logger.LogInformation($"Session {sessionId} already in terminal state '{existingStatus}', skipping status update to '{status}'"); + return false; + } + } + // Update status session["Status"] = status.ToString();