diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs index abf5e213..52a97b43 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs @@ -535,6 +535,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core if (response.Success) { _logger.Info($"Session registered successfully: {response.SessionId}"); + + if (!string.IsNullOrEmpty(response.AdminAction)) + { + _logger.Warning($"Session already marked as {response.AdminAction} by administrator — will run cleanup after startup"); + _pendingAdminAction = response.AdminAction; + } return; } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs index b958d246..a81b11f9 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs @@ -285,6 +285,30 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core return; } + if (!string.IsNullOrEmpty(response.AdminAction) && !_enrollmentTerminalEventSeen) + { + var succeeded = string.Equals(response.AdminAction, "Succeeded", StringComparison.OrdinalIgnoreCase); + _logger.Warning($"=== ADMIN OVERRIDE: Session marked as {response.AdminAction} by administrator. Initiating cleanup... ==="); + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = succeeded ? "enrollment_complete" : "enrollment_failed", + Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, + Source = "AdminOverride", + Phase = EnrollmentPhase.Complete, + Message = $"Session {response.AdminAction.ToLower()} by administrator — cleanup initiated", + Timestamp = DateTime.UtcNow, + Data = new Dictionary + { + { "adminAction", response.AdminAction } + } + }); + // EmitEvent triggers HandleEnrollmentComplete via the normal terminal event path + return; + } + if (response.Success) { _spool.RemoveEvents(events); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 64da7675..4736e3b6 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -76,6 +76,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow; private bool _enrollmentTerminalEventSeen; + // Admin override detected during session registration (agent restart after admin action) + private string _pendingAdminAction; + // Auth failure circuit breaker private int _consecutiveAuthFailures = 0; private DateTime? _firstAuthFailureTime = null; @@ -150,6 +153,34 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Register session with backend RegisterSessionAsync().Wait(); + // Check if admin already terminated this session before we restarted + if (!string.IsNullOrEmpty(_pendingAdminAction)) + { + var succeeded = string.Equals(_pendingAdminAction, "Succeeded", StringComparison.OrdinalIgnoreCase); + _logger.Warning($"=== ADMIN OVERRIDE on startup: Session already marked as {_pendingAdminAction} by administrator — running cleanup only ==="); + + // Start spool watcher briefly so the terminal event gets spooled for upload + _spool.StartWatching(); + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = succeeded ? "enrollment_complete" : "enrollment_failed", + Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, + Source = "AdminOverride", + Phase = EnrollmentPhase.Complete, + Message = $"Session {_pendingAdminAction.ToLower()} by administrator (detected on restart) — cleanup initiated", + Timestamp = DateTime.UtcNow, + Data = new Dictionary + { + { "adminAction", _pendingAdminAction } + } + }); + // EmitEvent triggers HandleEnrollmentComplete which handles cleanup + exit + return; + } + // Start FileSystemWatcher for efficient event detection _spool.StartWatching(); _logger.Info("FileSystemWatcher started for efficient event upload"); diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index b8639573..36e08261 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -476 +480 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 6c4ec958..ff7a5acf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -136,7 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 17, // unrestricted mode for tenant-level guardrail bypass + ConfigVersion = 18, // admin session override (mark-succeeded + agent cleanup signal via ingest response) UploadIntervalSeconds = 30, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs index 4cbe8316..c0992aae 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs @@ -242,6 +242,19 @@ namespace AutopilotMonitor.Functions.Functions.Ingest request, sessionPrefix, classification, updatedSession, statusTransitioned, whiteGloveStatusTransitioned, failureReason); + // Check if an admin has externally marked the session as terminal. + // Only signal the agent when this batch did NOT contain the terminal event itself + // (i.e., the agent didn't just report completion — the admin did it out-of-band). + string? adminAction = null; + if (updatedSession != null && + classification.CompletionEvent == null && classification.FailureEvent == null && + (updatedSession.Status == SessionStatus.Succeeded || updatedSession.Status == SessionStatus.Failed)) + { + adminAction = updatedSession.Status.ToString(); + _logger.LogInformation("{SessionPrefix} Admin override detected — signaling agent: AdminAction={AdminAction}", + sessionPrefix, adminAction); + } + // Build HTTP response + SignalR messages var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new IngestEventsResponse @@ -250,7 +263,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest EventsReceived = request.Events.Count, EventsProcessed = processedCount, Message = $"Successfully stored {processedCount} of {request.Events.Count} events", - ProcessedAt = DateTime.UtcNow + ProcessedAt = DateTime.UtcNow, + AdminAction = adminAction }); var signalRMessages = BuildSignalRMessages(request, updatedSession, processedCount, newRuleResults); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs new file mode 100644 index 00000000..b44a0990 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs @@ -0,0 +1,141 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + public class MarkSessionSucceededFunction + { + private readonly ILogger _logger; + private readonly TableStorageService _storageService; + + public MarkSessionSucceededFunction( + ILogger logger, + TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("MarkSessionSucceeded")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sessions/{sessionId}/mark-succeeded")] HttpRequestData req, + string sessionId) + { + _logger.LogInformation($"MarkSessionSucceeded function processing request for session {sessionId}"); + + try + { + // Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware + string tenantId = TenantHelper.GetTenantId(req); + string userIdentifier = TenantHelper.GetUserIdentifier(req); + + _logger.LogInformation($"Marking session {sessionId} as succeeded for tenant {tenantId} by user {userIdentifier}"); + + // Update session status to Succeeded with manual reason + var success = await _storageService.UpdateSessionStatusAsync( + tenantId, + sessionId, + SessionStatus.Succeeded, + null, // Keep current phase + "Manually marked as succeeded by administrator" + ); + + if (success) + { + // Log audit entry with actual user identifier + await _storageService.LogAuditEntryAsync( + tenantId, + "UPDATE", + "Session", + sessionId, + userIdentifier, + new Dictionary + { + { "Action", "MarkAsSucceeded" }, + { "Reason", "Manually marked as succeeded" } + } + ); + + // Retrieve updated session data to include in SignalR message + var updatedSession = await _storageService.GetSessionAsync(tenantId, sessionId); + + _logger.LogInformation($"Successfully marked session {sessionId} as succeeded"); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + success = true, + message = $"Session {sessionId} marked as succeeded" + }); + + // Send SignalR notification to update all clients in the tenant + object? sessionDelta = updatedSession != null ? new { + updatedSession.CurrentPhase, + updatedSession.CurrentPhaseDetail, + updatedSession.Status, + updatedSession.FailureReason, + updatedSession.EventCount, + updatedSession.DurationSeconds, + updatedSession.CompletedAt, + updatedSession.DiagnosticsBlobName + } : null; + + var tenantMessage = new SignalRMessageAction("newevents") + { + GroupName = $"tenant-{tenantId}", + Arguments = new object[] { new { + sessionId = sessionId, + tenantId = tenantId, + eventCount = 0, + sessionUpdate = sessionDelta + } } + }; + + return new MarkSessionSucceededOutput + { + HttpResponse = response, + SignalRMessages = new[] { tenantMessage } + }; + } + else + { + _logger.LogWarning($"Session {sessionId} not found"); + var response = req.CreateResponse(HttpStatusCode.NotFound); + await response.WriteAsJsonAsync(new + { + success = false, + message = $"Session {sessionId} not found" + }); + return new MarkSessionSucceededOutput { HttpResponse = response }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error marking session {sessionId} as succeeded"); + + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new + { + success = false, + message = "Internal server error" + }); + + return new MarkSessionSucceededOutput { HttpResponse = errorResponse }; + } + } + } + + public class MarkSessionSucceededOutput + { + [HttpResult] + public HttpResponseData? HttpResponse { get; set; } + + [SignalROutput(HubName = "autopilotmonitor")] + public SignalRMessageAction[]? SignalRMessages { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index bf6e75f6..faa29981 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -116,13 +116,23 @@ namespace AutopilotMonitor.Functions.Functions.Sessions // Retrieve the stored session to include full data in SignalR message var session = await _storageService.GetSessionAsync(registration.TenantId, registration.SessionId); + // Check if admin has already marked this session as terminal (e.g., agent restarted after admin override) + string? adminAction = null; + if (session != null && (session.Status == SessionStatus.Succeeded || session.Status == SessionStatus.Failed)) + { + adminAction = session.Status.ToString(); + _logger.LogInformation("Session {SessionId} already in terminal state {Status} — signaling agent: AdminAction={AdminAction}", + registration.SessionId, session.Status, adminAction); + } + var response = req.CreateResponse(HttpStatusCode.OK); var responseData = new RegisterSessionResponse { SessionId = registration.SessionId, Success = true, Message = "Session registered successfully", - RegisteredAt = DateTime.UtcNow + RegisteredAt = DateTime.UtcNow, + AdminAction = adminAction }; await response.WriteAsJsonAsync(responseData); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 0afadb6f..e3e1847b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -20,6 +20,12 @@ namespace AutopilotMonitor.Shared.Models public bool Success { get; set; } public string Message { get; set; } public DateTime RegisteredAt { get; set; } + + /// + /// Non-null when the session was already marked as terminal by an admin before agent restart. + /// Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring. + /// + public string AdminAction { get; set; } } /// @@ -74,6 +80,12 @@ namespace AutopilotMonitor.Shared.Models /// The agent should execute its self-destruct routine and exit. /// public bool DeviceKillSignal { get; set; } + + /// + /// Non-null when an admin has externally changed the session status. + /// Values: "Succeeded", "Failed". Agent should treat as terminal signal and run cleanup. + /// + public string AdminAction { get; set; } } /// diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AdminOverrideModal.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AdminOverrideModal.tsx new file mode 100644 index 00000000..6cd82c14 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AdminOverrideModal.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Session } from "@/types"; + +interface AdminOverrideModalProps { + show: boolean; + action: "failed" | "succeeded"; + session: Session | null; + onConfirm: () => void; + onCancel: () => void; +} + +export default function AdminOverrideModal({ show, action, session, onConfirm, onCancel }: AdminOverrideModalProps) { + if (!show || !session) return null; + + const isFailed = action === "failed"; + const colorClass = isFailed ? "red" : "green"; + const label = isFailed ? "Failed" : "Succeeded"; + + return ( +
+
e.stopPropagation()}> +
+
+
+ {isFailed ? ( + + + + ) : ( + + + + )} +
+

Mark Session as {label}

+
+
+

+ You are about to manually mark this session as {label}. +

+

+ Session {session.sessionId} for device {session.deviceName || session.serialNumber} will be marked as {action} with the reason "Manually marked as {action} by administrator". +

+
+

Agent Cleanup Warning

+

+ This action will trigger the agent to stop monitoring and perform cleanup on the device (final data upload, diagnostics collection, and potentially self-destruct based on tenant configuration). If the agent is currently idle, the cleanup will occur on the next device restart. +

+
+

+ This action cannot be undone. Do you want to continue? +

+
+
+ + +
+
+
+
+ ); +} 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 1508f24f..140509ff 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -20,7 +20,7 @@ import SessionInfoCard from "./components/SessionInfoCard"; import PhaseTimeline from "./components/PhaseTimeline"; import EventTimeline from "./components/EventTimeline"; import AnalysisResultsSection from "./components/AnalysisResultsSection"; -import MarkFailedModal from "./components/MarkFailedModal"; +import AdminOverrideModal from "./components/AdminOverrideModal"; import ReportSessionModal from "./components/ReportSessionModal"; import { usePageSections } from "../../../hooks/usePageSections"; import { PageSectionItem } from "../../../contexts/SidebarContext"; @@ -41,6 +41,7 @@ export default function SessionDetailPage() { const [loading, setLoading] = useState(true); const [severityFilters, setSeverityFilters] = useState>(new Set(["Info", "Warning", "Error", "Critical"])); const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false); + const [showMarkSucceededConfirm, setShowMarkSucceededConfirm] = useState(false); const [showReportModal, setShowReportModal] = useState(false); const [reportSubmitting, setReportSubmitting] = useState(false); const [showScriptOutput, setShowScriptOutput] = useState(true); @@ -380,8 +381,11 @@ export default function SessionDetailPage() { setShowMarkFailedConfirm(true); }; + const markAsSucceeded = () => { + setShowMarkSucceededConfirm(true); + }; + const confirmMarkFailed = async () => { - // Use the session's tenant ID if available const effectiveTenantId = sessionTenantId || tenantId; try { @@ -393,12 +397,8 @@ export default function SessionDetailPage() { if (response.ok) { setShowMarkFailedConfirm(false); - // Session will be updated via SignalR, but we can also update local state immediately if (session) { - setSession({ - ...session, - status: 'Failed' - }); + setSession({ ...session, status: 'Failed' }); } } else { console.error('Failed to mark session as failed'); @@ -408,10 +408,37 @@ export default function SessionDetailPage() { } }; + const confirmMarkSucceeded = async () => { + const effectiveTenantId = sessionTenantId || tenantId; + + try { + const response = await authenticatedFetch( + `${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded?tenantId=${effectiveTenantId}`, + getAccessToken, + { method: 'POST' } + ); + + if (response.ok) { + setShowMarkSucceededConfirm(false); + if (session) { + setSession({ ...session, status: 'Succeeded' }); + } + } else { + console.error('Failed to mark session as succeeded'); + } + } catch (error) { + console.error('Error marking session as succeeded:', error); + } + }; + const cancelMarkFailed = () => { setShowMarkFailedConfirm(false); }; + const cancelMarkSucceeded = () => { + setShowMarkSucceededConfirm(false); + }; + const handleSubmitReport = async ( comment: string, email: string, screenshotBase64: string | null, screenshotFileName: string | null, @@ -802,15 +829,26 @@ export default function SessionDetailPage() { )} {adminMode && (session?.status === 'InProgress' || session?.status === 'Pending') && ( - + <> + + + )}