added mark as success

This commit is contained in:
Oliver Kieselbach
2026-03-18 10:08:15 +01:00
parent 7aadda02d6
commit 248e118025
12 changed files with 377 additions and 23 deletions
@@ -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;
}
@@ -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<string, object>
{
{ "adminAction", response.AdminAction }
}
});
// EmitEvent triggers HandleEnrollmentComplete via the normal terminal event path
return;
}
if (response.Success)
{
_spool.RemoveEvents(events);
@@ -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<string, object>
{
{ "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");
@@ -1 +1 @@
476
480
@@ -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,
@@ -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);
@@ -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<MarkSessionSucceededFunction> _logger;
private readonly TableStorageService _storageService;
public MarkSessionSucceededFunction(
ILogger<MarkSessionSucceededFunction> logger,
TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("MarkSessionSucceeded")]
public async Task<MarkSessionSucceededOutput> 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<string, string>
{
{ "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; }
}
}
@@ -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);
@@ -20,6 +20,12 @@ namespace AutopilotMonitor.Shared.Models
public bool Success { get; set; }
public string Message { get; set; }
public DateTime RegisteredAt { get; set; }
/// <summary>
/// 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.
/// </summary>
public string AdminAction { get; set; }
}
/// <summary>
@@ -74,6 +80,12 @@ namespace AutopilotMonitor.Shared.Models
/// The agent should execute its self-destruct routine and exit.
/// </summary>
public bool DeviceKillSignal { get; set; }
/// <summary>
/// Non-null when an admin has externally changed the session status.
/// Values: "Succeeded", "Failed". Agent should treat as terminal signal and run cleanup.
/// </summary>
public string AdminAction { get; set; }
}
/// <summary>
@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className={`flex-shrink-0 w-12 h-12 bg-${colorClass}-100 rounded-full flex items-center justify-center`}>
{isFailed ? (
<svg className={`w-6 h-6 text-${colorClass}-600`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
) : (
<svg className={`w-6 h-6 text-${colorClass}-600`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Mark Session as {label}</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
You are about to manually mark this session as <span className={`font-semibold text-${colorClass}-600`}>{label}</span>.
</p>
<p className="text-sm text-gray-700 mb-2">
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as {action} with the reason &quot;Manually marked as {action} by administrator&quot;.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 mt-3">
<p className="text-sm text-amber-800 font-medium mb-1">Agent Cleanup Warning</p>
<p className="text-xs text-amber-700">
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.
</p>
</div>
<p className="text-sm text-gray-600 mt-3">
This action cannot be undone. Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors">
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 ${isFailed ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'} text-white rounded-md transition-colors`}
>
Mark as {label}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -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<Set<string>>(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() {
</button>
)}
{adminMode && (session?.status === 'InProgress' || session?.status === 'Pending') && (
<button
onClick={markAsFailed}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Mark as Failed
</button>
<>
<button
onClick={markAsSucceeded}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Mark as Succeeded
</button>
<button
onClick={markAsFailed}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Mark as Failed
</button>
</>
)}
<button
onClick={() => setShowReportModal(true)}
@@ -958,13 +996,21 @@ export default function SessionDetailPage() {
</div>
</div>
{/* Mark as Failed Confirmation Modal */}
<MarkFailedModal
{/* Admin Override Confirmation Modals */}
<AdminOverrideModal
show={showMarkFailedConfirm}
action="failed"
session={session}
onConfirm={confirmMarkFailed}
onCancel={cancelMarkFailed}
/>
<AdminOverrideModal
show={showMarkSucceededConfirm}
action="succeeded"
session={session}
onConfirm={confirmMarkSucceeded}
onCancel={cancelMarkSucceeded}
/>
{/* Report Session Modal */}
<ReportSessionModal
File diff suppressed because one or more lines are too long