diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/PerformanceCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/PerformanceCollector.cs index 1957245a..33bc73eb 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/PerformanceCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/PerformanceCollector.cs @@ -20,24 +20,30 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors private readonly string _tenantId; private readonly Action _onEventCollected; private readonly int _intervalSeconds; + private readonly int _maxDurationHours; + private DateTime _startedAt; private Timer _pollTimer; private PerformanceCounter _cpuCounter; private PerformanceCounter _diskQueueCounter; public PerformanceCollector(string sessionId, string tenantId, Action onEventCollected, - AgentLogger logger, int intervalSeconds = 60) + AgentLogger logger, int intervalSeconds = 60, int maxDurationHours = 4) { _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; + _maxDurationHours = maxDurationHours; } public void Start() { - _logger.Info($"Starting Performance collector (interval: {_intervalSeconds}s)"); + var limitInfo = _maxDurationHours > 0 ? $", max duration: {_maxDurationHours}h" : ", no duration limit"; + _logger.Info($"Starting Performance collector (interval: {_intervalSeconds}s{limitInfo})"); + + _startedAt = DateTime.UtcNow; try { @@ -72,6 +78,30 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors { try { + // Stop after max duration to avoid unbounded backend traffic when a session is stuck + if (_maxDurationHours > 0 && (DateTime.UtcNow - _startedAt).TotalHours >= _maxDurationHours) + { + _logger.Info($"Performance collector max duration reached ({_maxDurationHours}h) — stopping"); + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = DateTime.UtcNow, + EventType = "performance_collector_stopped", + Severity = EventSeverity.Info, + Source = "PerformanceCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"Performance collector stopped after {_maxDurationHours}h (max duration policy)", + Data = new Dictionary + { + { "reason", "max_duration_reached" }, + { "maxDurationHours", _maxDurationHours } + } + }); + Stop(); + return; + } + var data = new Dictionary(); // CPU usage diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index f3cf7237..362a58b6 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -286,12 +286,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core { // PerformanceCollector is always on (feeds UI chart) var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60; + var maxCollectorDurationHours = collectors?.MaxCollectorDurationHours ?? 4; _performanceCollector = new PerformanceCollector( _configuration.SessionId, _configuration.TenantId, EmitEvent, _logger, - perfInterval + perfInterval, + maxCollectorDurationHours ); _performanceCollector.Start(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs index 6d528552..2e451752 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs @@ -17,6 +17,7 @@ namespace AutopilotMonitor.Functions.Functions { private readonly ILogger _logger; private readonly TenantConfigurationService _configService; + private readonly AdminConfigurationService _adminConfigService; private readonly GatherRuleService _gatherRuleService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; @@ -24,12 +25,14 @@ namespace AutopilotMonitor.Functions.Functions public GetAgentConfigFunction( ILogger logger, TenantConfigurationService configService, + AdminConfigurationService adminConfigService, GatherRuleService gatherRuleService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator) { _logger = logger; _configService = configService; + _adminConfigService = adminConfigService; _gatherRuleService = gatherRuleService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; @@ -74,11 +77,15 @@ namespace AutopilotMonitor.Functions.Functions // Get tenant configuration var tenantConfig = await _configService.GetConfigurationAsync(tenantId); - // Build collector configuration from tenant settings + // Get global admin config for platform-wide policy settings + var adminConfig = await _adminConfigService.GetConfigurationAsync(); + + // Build collector configuration from tenant settings + global policy var collectors = new CollectorConfiguration { EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector, - PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds + PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds, + MaxCollectorDurationHours = adminConfig.MaxCollectorDurationHours }; // Get active gather rules for this tenant (user-defined ad-hoc only) @@ -90,7 +97,7 @@ namespace AutopilotMonitor.Functions.Functions var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 6, + ConfigVersion = 7, UploadIntervalSeconds = 30, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index e9f14482..00c34f29 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -48,15 +48,41 @@ namespace AutopilotMonitor.Functions.Functions { try { - // Parse NDJSON+gzip request - IngestEventsRequest? request = null; + // --- Security checks FIRST — before touching the request body --- - // Extract tenantId from header to get config (for payload size limit) + // TenantId is available in the X-Tenant-Id header, so we can validate the request + // before paying the cost of gzip decompression and JSON parsing. var tenantIdHeader = req.Headers.Contains("X-Tenant-Id") ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() : null; - request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader); + if (string.IsNullOrEmpty(tenantIdHeader)) + { + return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "X-Tenant-Id header is required"); + } + + // Validate request security (certificate, rate limit, hardware whitelist, serial number in autopilot) + // sessionId is not yet known at this point — that's fine, it's optional for logging only. + var (validation, errorResponse) = await req.ValidateSecurityAsync( + tenantIdHeader, + _configService, + _rateLimitService, + _autopilotDeviceValidator, + _logger + ); + + if (errorResponse != null) + { + // Security validation failed - return before parsing the body + return new IngestEventsOutput + { + HttpResponse = errorResponse, + SignalRMessages = Array.Empty() + }; + } + + // --- Parse NDJSON+gzip request body (only after security is cleared) --- + var request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader); if (request?.Events == null || request.Events.Count == 0) { @@ -68,24 +94,11 @@ namespace AutopilotMonitor.Functions.Functions return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required"); } - // Validate request security (certificate, rate limit, hardware whitelist, serial number in autopilot) - var (validation, errorResponse) = await req.ValidateSecurityAsync( - request.TenantId, - _configService, - _rateLimitService, - _autopilotDeviceValidator, - _logger, - request.SessionId - ); - - if (errorResponse != null) + // Ensure body TenantId matches the validated header TenantId (prevent body spoofing) + if (!string.Equals(request.TenantId, tenantIdHeader, StringComparison.OrdinalIgnoreCase)) { - // Security validation failed - return error response - return new IngestEventsOutput - { - HttpResponse = errorResponse, - SignalRMessages = Array.Empty() - }; + _logger.LogWarning("TenantId mismatch: header={HeaderTenantId}, body={BodyTenantId}", tenantIdHeader, request.TenantId); + return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload"); } var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]"; @@ -340,22 +353,10 @@ namespace AutopilotMonitor.Functions.Functions } } }; - // 2. Detailed events for real-time event streaming on detail pages (session-specific) - // Only sent to clients viewing this specific session - massive cost savings - // Strip redundant sessionId/tenantId from individual events and rule results - var slimEvents = storedEvents.Select(e => new { - e.EventId, - e.Timestamp, - e.EventType, - severity = e.SeverityString, - e.Source, - phase = e.PhaseNumber, - phaseName = e.PhaseName, - e.Message, - e.Data, - e.Sequence - }).ToList(); - + // 2. Signal for real-time event streaming on detail pages (session-specific) + // Only sent to clients viewing this specific session - cost-efficient signal pattern: + // frontend fetches full events from Table Storage on receipt, ensuring canonical truth + // and eliminating SignalR gap issues. Events payload omitted intentionally. var slimRuleResults = newRuleResults.Count > 0 ? newRuleResults.Select(r => new { r.ResultId, @@ -378,7 +379,7 @@ namespace AutopilotMonitor.Functions.Functions Arguments = new object[] { new { sessionId = request.SessionId, tenantId = request.TenantId, - events = slimEvents, + newEventCount = processedCount, session = updatedSession, newRuleResults = slimRuleResults } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index 5a59694d..48d67de7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -181,7 +181,8 @@ namespace AutopilotMonitor.Functions.Services { "LastUpdated", config.LastUpdated }, { "UpdatedBy", config.UpdatedBy }, { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute }, - { "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty } + { "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty }, + { "MaxCollectorDurationHours", config.MaxCollectorDurationHours } }; return entity; @@ -196,7 +197,8 @@ namespace AutopilotMonitor.Functions.Services LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100, - PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty + PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty, + MaxCollectorDurationHours = entity.GetInt32("MaxCollectorDurationHours") ?? 4 }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs index 326a29e6..68e0441a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs @@ -45,6 +45,14 @@ namespace AutopilotMonitor.Shared.Models /// public string PlatformStatsBlobSasUrl { get; set; } = string.Empty; + /// + /// Maximum duration in hours that interval-based collectors (e.g. PerformanceCollector) + /// keep running per session. After this time they stop automatically to prevent + /// excessive backend traffic when a device is stuck during enrollment. + /// 0 = no limit (run forever). Default: 4 hours. + /// + public int MaxCollectorDurationHours { get; set; } = 4; + /// /// Creates default configuration /// @@ -57,7 +65,8 @@ namespace AutopilotMonitor.Shared.Models LastUpdated = DateTime.UtcNow, UpdatedBy = "System", GlobalRateLimitRequestsPerMinute = 100, - PlatformStatsBlobSasUrl = string.Empty + PlatformStatsBlobSasUrl = string.Empty, + MaxCollectorDurationHours = 4 }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs index 5ba68617..0809daa6 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs @@ -123,6 +123,12 @@ namespace AutopilotMonitor.Shared.Models /// public int PerformanceIntervalSeconds { get; set; } = 30; + /// + /// Maximum duration in hours that interval-based collectors run before stopping themselves. + /// 0 = no limit. Default: 4 hours. + /// + public int MaxCollectorDurationHours { get; set; } = 4; + /// /// Creates default collector configuration /// diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx index f68236a7..3f30bc09 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -6,6 +6,36 @@ import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; import { API_BASE_URL } from "@/lib/config"; +interface SessionExportEvent { + eventId: string; + sessionId: string; + tenantId: string; + timestamp: string; + eventType: string; + severity: string; + source: string; + phase: number; + phaseName?: string; + message: string; + sequence: number; + data?: Record; +} + +const EXPORT_V1_PHASE_NAMES: Record = { + 0: "Start", 1: "Device Preparation", 2: "Device Setup", + 3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)", + 6: "Finalizing Setup", 7: "Complete", 99: "Failed" +}; +const EXPORT_V2_PHASE_NAMES: Record = { + 0: "Start", 1: "Device Preparation", 2: "Device Setup", + 3: "App Installation", 4: "Account Setup", 5: "Apps (User)", + 6: "Finalizing Setup", 7: "Complete", 99: "Failed" +}; +const EXPORT_V1_PHASE_ORDER = ["Start", "Device Preparation", "Device Setup", + "Apps (Device)", "Account Setup", "Apps (User)", "Finalizing Setup", "Complete", "Failed"]; +const EXPORT_V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation", + "Finalizing Setup", "Complete", "Failed"]; + interface AdminConfiguration { partitionKey: string; rowKey: string; @@ -13,6 +43,7 @@ interface AdminConfiguration { updatedBy: string; globalRateLimitRequestsPerMinute: number; platformStatsBlobSasUrl?: string; + maxCollectorDurationHours?: number; customSettings?: string; } @@ -58,6 +89,14 @@ export default function AdminConfigurationPage() { const [savingConfig, setSavingConfig] = useState(false); const [globalRateLimit, setGlobalRateLimit] = useState(100); const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState(""); + const [maxCollectorDurationHours, setMaxCollectorDurationHours] = useState(4); + + // Session Event Export state + const [exportSessionId, setExportSessionId] = useState(""); + const [exportTenantId, setExportTenantId] = useState(""); + const [exportLoading, setExportLoading] = useState(false); + const [exportError, setExportError] = useState(null); + const [exportedEvents, setExportedEvents] = useState(null); // Tenant Management state const [tenants, setTenants] = useState([]); @@ -127,6 +166,7 @@ export default function AdminConfigurationPage() { setAdminConfig(data); setGlobalRateLimit(data.globalRateLimitRequestsPerMinute); setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? ""); + setMaxCollectorDurationHours(data.maxCollectorDurationHours ?? 4); } catch (err) { console.error("Error fetching admin configuration:", err); setError(err instanceof Error ? err.message : "Failed to load admin configuration"); @@ -267,6 +307,7 @@ export default function AdminConfigurationPage() { ...adminConfig, globalRateLimitRequestsPerMinute: globalRateLimit, platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(), + maxCollectorDurationHours: maxCollectorDurationHours, }; const token = await getAccessToken(); @@ -305,6 +346,7 @@ export default function AdminConfigurationPage() { if (!adminConfig) return; setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute); setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? ""); + setMaxCollectorDurationHours(adminConfig.maxCollectorDurationHours ?? 4); setSuccessMessage(null); setError(null); }; @@ -636,6 +678,126 @@ export default function AdminConfigurationPage() { const endAdminIndex = startAdminIndex + adminsPerPage; const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex); + const handleFetchExportEvents = async () => { + const sid = exportSessionId.trim(); + const tid = exportTenantId.trim(); + if (!sid || !tid) { + setExportError("Session ID and Tenant ID are required."); + return; + } + try { + setExportLoading(true); + setExportError(null); + setExportedEvents(null); + const token = await getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + const res = await fetch( + `${API_BASE_URL}/api/sessions/${sid}/events?tenantId=${encodeURIComponent(tid)}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + const data = await res.json(); + if (!data.success) throw new Error(data.message || "Backend returned error"); + setExportedEvents(data.events ?? []); + } catch (err) { + setExportError(err instanceof Error ? err.message : "Failed to fetch events"); + } finally { + setExportLoading(false); + } + }; + + const downloadFile = (content: string, filename: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }; + + const generateCsvExport = (events: SessionExportEvent[]) => { + const isV1 = events.some(e => e.phase === 2); + const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; + const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; + const header = "EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,PhaseName,Message,Sequence,Data"; + const rows = events.map(e => [ + esc(e.eventId ?? ""), + esc(e.sessionId ?? ""), + esc(e.tenantId ?? ""), + esc(e.timestamp ?? ""), + esc(e.eventType ?? ""), + esc(e.severity ?? ""), + esc(e.source ?? ""), + String(e.phase ?? 0), + esc(phaseNames[e.phase] ?? "Unknown"), + esc(e.message ?? ""), + String(e.sequence ?? 0), + esc(e.data ? JSON.stringify(e.data) : ""), + ].join(",")); + return "\uFEFF" + header + "\n" + rows.join("\n"); + }; + + const generateUiExport = (events: SessionExportEvent[], sessionId: string, tenantId: string) => { + const isV1 = events.some(e => e.phase === 2); + const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; + const phaseOrder = isV1 ? EXPORT_V1_PHASE_ORDER : EXPORT_V2_PHASE_ORDER; + + const sorted = [...events].sort((a, b) => a.sequence - b.sequence); + + // Replicate eventsByPhase useMemo logic: assign Unknown-phase events to active named phase + const grouped: Record = {}; + phaseOrder.forEach(p => { grouped[p] = []; }); + + let lastNamedPhase = phaseOrder[0]; + for (const ev of sorted) { + const name = phaseNames[ev.phase]; + if (name && name !== "Unknown") { + lastNamedPhase = name; + if (grouped[name]) grouped[name].push({ ...ev, phaseName: name }); + } else { + // Unknown phase — insert into currently active named phase + if (grouped[lastNamedPhase]) grouped[lastNamedPhase].push({ ...ev, phaseName: "Unknown" }); + } + } + + const pad = (s: string, len: number) => s.padEnd(len); + const severityLabel = (s: string) => pad(s ?? "Unknown", 7); + + const lines: string[] = []; + lines.push("AUTOPILOT MONITOR \u2014 SESSION EVENT EXPORT"); + lines.push("========================================="); + lines.push(`Session ID : ${sessionId}`); + lines.push(`Tenant ID : ${tenantId}`); + lines.push(`Exported at : ${new Date().toISOString()}`); + lines.push(`Total events : ${events.length}`); + lines.push(`Enrollment : ${isV1 ? "V1" : "V2"}`); + + for (const phase of phaseOrder) { + const phaseEvents = grouped[phase] ?? []; + lines.push(""); + lines.push("\u2550".repeat(43)); + lines.push(` ${phase} (${phaseEvents.length} event${phaseEvents.length !== 1 ? "s" : ""})`); + lines.push("\u2550".repeat(43)); + if (phaseEvents.length === 0) { + lines.push(" (no events)"); + } else { + for (const ev of phaseEvents) { + const ts = ev.timestamp ? new Date(ev.timestamp).toISOString().replace("T", " ").substring(0, 23) : "?"; + lines.push(`[${ts}] [${severityLabel(ev.severity)}] ${ev.eventType} \u2014 ${ev.message}`); + let detail = ` Source: ${ev.source ?? "?"} | Seq: ${ev.sequence ?? "?"} | EventId: ${ev.eventId ?? "?"}`; + if (ev.phaseName === "Unknown") detail += ` | RawPhase: ${ev.phase}`; + lines.push(detail); + if (ev.data && Object.keys(ev.data).length > 0) { + lines.push(` Data: ${JSON.stringify(ev.data)}`); + } + } + } + } + + return lines.join("\n"); + }; + if (!galacticAdminMode) { return null; } @@ -1043,6 +1205,130 @@ export default function AdminConfigurationPage() { + {/* Session Event Export Tool */} +
+
+
+ + + +
+

Session Event Export

+

Fetch and export all events directly from storage — use to analyze timeline phase grouping and ordering

+
+
+
+
+
+
+
+ + setExportSessionId(e.target.value)} + className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" + /> +
+
+ + setExportTenantId(e.target.value)} + className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" + /> +
+
+ +
+ +
+ + {exportError && ( +
+ + + + {exportError} +
+ )} + + {exportedEvents !== null && ( +
+
+ + + + + {exportedEvents.length} events loaded + {" \u00B7 "} + {exportedEvents.some(e => e.phase === 2) ? "V1" : "V2"} + {" \u00B7 "} + session {exportSessionId.trim().slice(0, 8)} + +
+
+ + +
+
+ )} +
+
+
+ {/* Global Rate Limiting Configuration */}
@@ -1103,6 +1389,25 @@ export default function AdminConfigurationPage() {
+
+ +
+ {adminConfig && (
diff --git a/src/Web/autopilot-monitor-web/app/changelog/page.tsx b/src/Web/autopilot-monitor-web/app/changelog/page.tsx new file mode 100644 index 00000000..fdc75c20 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/changelog/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +import { PublicPageHeader } from "../../components/PublicPageHeader"; +import { PublicSiteNavbar } from "../../components/PublicSiteNavbar"; + +export default function ChangelogPage() { + return ( +
+ + +
+
+ + {/* Intro */} +
+

+ This changelog tracks significant platform changes during Private Preview — + architecture updates, data flow changes, and anything else that might briefly + affect the UI or monitoring data. If something looks off, check here first. + A recent entry might explain it. +

+

+ Found a bug or want to give feedback?{" "} + + Open a GitHub Issue + + {" "}— it helps more than you might think. +

+
+ + {/* Entries — newest first */} +
+ +
+
+ + 2026-02-26 + + + Architecture + +
+

+ Reworked real-time event delivery and session timeline processing +

+

+ The way live session events reach the dashboard timeline was fundamentally + reworked. Previously, SignalR pushed full event payloads directly into the + UI — which could cause silent gaps if a message was dropped or arrived + out of order. Events are now fetched directly from storage on each signal, + making Table Storage the single source of truth. The timeline always reflects + what is actually stored, ordering and completeness are guaranteed, and + phase grouping is computed at render time rather than at receipt time + (eliminating a race condition when session metadata wasn't yet available). + A 30-second catch-up fetch was also added as a safety net for sessions + with degraded SignalR connectivity. +

+
+ +
+ +
+ + Landing page + +
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index fe3857c4..d87f1cd0 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useRef } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useSignalR } from "../../contexts/SignalRContext"; @@ -495,6 +496,33 @@ export default function Home() {

+ {/* Feedback & bug report banner */} +
+ + + +

+ Private Preview — ongoing platform updates.{" "} + Check the{" "} + + Private Preview Changelog + + {" "}if something looks off — recent changes are listed there. Feedback or a bug to report?{" "} + + Open a GitHub Issue + + {" "}— or reach out via LinkedIn DM. +

+
+ {serialValidationEnabled === false && (
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 5a72691a..0c94e876 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -199,37 +199,27 @@ export default function SessionDetailPage() { // Setup SignalR listener - re-register when connection changes useEffect(() => { - // Listen for real-time event stream (cost-efficient - no HTTP requests!) - const handleEventStream = (data: { sessionId: string; tenantId: string; events: EnrollmentEvent[]; session: Session; newRuleResults?: RuleResult[] }) => { - console.log('Event stream received via SignalR:', data); - if (data.sessionId === sessionIdRef.current && data.events && data.events.length > 0) { - // Add phase names to new events - const phaseNamesMap = data.session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES; - const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({ - ...e, - phaseName: phaseNamesMap[e.phase] || "Unknown" - })); + // Listen for event stream signal — backend sends a lightweight signal (no event payload). + // Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps. + const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; session?: Session; newRuleResults?: RuleResult[] }) => { + console.log('Event stream signal received via SignalR:', data); + if (data.sessionId !== sessionIdRef.current) return; - // Add new events to existing list (deduplication by eventId) - setEvents(prevEvents => { - const existingIds = new Set(prevEvents.map(e => e.eventId)); - const newEvents = eventsWithPhaseNames.filter(e => !existingIds.has(e.eventId)); - return [...prevEvents, ...newEvents].sort((a, b) => a.sequence - b.sequence); + // Fetch full events from storage (single source of truth) + fetchEvents(); + + // Session update from SignalR (no extra fetch needed) + if (data.session) { + setSession(data.session); + } + + // Rule results from SignalR (only on enrollment completion) + if (data.newRuleResults && data.newRuleResults.length > 0) { + setAnalysisResults(prev => { + const existingIds = new Set(prev.map(r => r.ruleId)); + const newResults = data.newRuleResults!.filter(r => !existingIds.has(r.ruleId)); + return [...prev, ...newResults].sort((a, b) => b.confidenceScore - a.confidenceScore); }); - - // Update session details directly from SignalR (no HTTP request needed!) - if (data.session) { - setSession(data.session); - } - - // Add new rule results in real-time - if (data.newRuleResults && data.newRuleResults.length > 0) { - setAnalysisResults(prev => { - const existingIds = new Set(prev.map(r => r.ruleId)); - const newResults = data.newRuleResults!.filter(r => !existingIds.has(r.ruleId)); - return [...prev, ...newResults].sort((a, b) => b.confidenceScore - a.confidenceScore); - }); - } } }; @@ -256,6 +246,17 @@ export default function SessionDetailPage() { }; }, [on, off]); // Re-register when SignalR connection changes + // Periodic catch-up fetch — fills any SignalR gaps every 30s when tab is visible + useEffect(() => { + const effectiveTenantId = sessionTenantId || tenantId; + if (!sessionId || !effectiveTenantId) return; + const interval = setInterval(() => { + if (document.visibilityState === "visible") fetchEvents(); + }, 30_000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, sessionTenantId, tenantId]); + const fetchSessionDetails = async () => { try { // In galactic admin mode, fetch from the galactic endpoint (cross-tenant) @@ -314,21 +315,10 @@ export default function SessionDetailPage() { }); if (response.ok) { const data = await response.json(); - const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES; - const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({ - ...e, - phaseName: phaseNamesMap[e.phase] || "Unknown" - })); - // Merge with existing events (from SignalR) instead of replacing, - // so we don't lose events that arrived via SignalR since the last fetch. - // Deduplication by eventId, same as the SignalR handler. - setEvents(prevEvents => { - if (prevEvents.length === 0) return eventsWithPhaseNames; - const existingIds = new Set(prevEvents.map(e => e.eventId)); - const newEvents = eventsWithPhaseNames.filter((e: EnrollmentEvent) => !existingIds.has(e.eventId)); - if (newEvents.length === 0) return prevEvents; - return [...prevEvents, ...newEvents].sort((a: EnrollmentEvent, b: EnrollmentEvent) => a.sequence - b.sequence); - }); + // Replace entire event list with fresh data from Table Storage (canonical truth). + // No merge needed — fetchEvents() is the single source; SignalR only signals. + // phaseName is computed at render time in eventsByPhase useMemo (no race condition). + setEvents(data.events ?? []); } else { addNotification('error', 'Backend Error', `Failed to load session events: ${response.statusText}`, 'session-events-fetch-error'); } @@ -452,8 +442,13 @@ export default function SessionDetailPage() { // 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); + // Compute phaseName at render time so enrollmentType is always resolved (no race condition) + const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES; + + // Sort events by sequence (chronological order), enriching phaseName from current session + const sortedEvents = [...filteredEvents] + .sort((a, b) => a.sequence - b.sequence) + .map(e => ({ ...e, phaseName: phaseNamesMap[e.phase] ?? "Unknown" })); // Group events by phase, inserting "Unknown" events into their chronological position const eventsByPhase = {} as Record; diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index 137852d9..e696021d 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -480,6 +480,17 @@ export default function Navbar() { Roadmap + setShowOverflow(false)} + > + + + + Preview Changelog + +