diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs index c64bb371..75b600f3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs @@ -8,18 +8,23 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions { /// - /// Returns analysis results (rule evaluations) for a session - /// Used by the session detail page to show detected issues + /// Returns analysis results (rule evaluations) for a session. + /// Supports on-demand re-analysis via ?reanalyze=true query parameter. /// public class GetRuleResultsFunction { private readonly ILogger _logger; private readonly TableStorageService _storageService; + private readonly AnalyzeRuleService _analyzeRuleService; - public GetRuleResultsFunction(ILogger logger, TableStorageService storageService) + public GetRuleResultsFunction( + ILogger logger, + TableStorageService storageService, + AnalyzeRuleService analyzeRuleService) { _logger = logger; _storageService = storageService; + _analyzeRuleService = analyzeRuleService; } [Function("GetRuleResults")] @@ -42,6 +47,33 @@ namespace AutopilotMonitor.Functions.Functions return unauthorized; } + // On-demand re-analysis: run all rules against all session events + var query2 = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var reanalyze = string.Equals(query2["reanalyze"], "true", StringComparison.OrdinalIgnoreCase); + + if (reanalyze) + { + try + { + var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); + var newResults = await ruleEngine.AnalyzeSessionAsync(tenantId, sessionId); + + foreach (var result in newResults) + { + await _storageService.StoreRuleResultAsync(result); + } + + if (newResults.Count > 0) + { + _logger.LogInformation($"On-demand analysis for session {sessionId}: {newResults.Count} new issue(s) detected"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"On-demand analysis failed for session {sessionId}"); + } + } + var results = await _storageService.GetRuleResultsAsync(tenantId, sessionId); var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 7a520eb6..6dccd528 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -178,27 +178,29 @@ namespace AutopilotMonitor.Functions.Functions ); } - // Run analyze rules against new events + // Run full analysis when enrollment ends (cost-efficient: one pass over all events) var newRuleResults = new List(); - try + if (completionEvent != null || failureEvent != null) { - var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); - var allEvents = await _storageService.GetSessionEventsAsync(request.TenantId, request.SessionId); - newRuleResults = await ruleEngine.EvaluateRulesAsync(request.TenantId, request.SessionId, storedEvents, allEvents); - - foreach (var result in newRuleResults) + try { - await _storageService.StoreRuleResultAsync(result); - } + var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); + newRuleResults = await ruleEngine.AnalyzeSessionAsync(request.TenantId, request.SessionId); - if (newRuleResults.Count > 0) - { - _logger.LogInformation($"{sessionPrefix} Rule engine: {newRuleResults.Count} new issue(s) detected"); + foreach (var result in newRuleResults) + { + await _storageService.StoreRuleResultAsync(result); + } + + if (newRuleResults.Count > 0) + { + _logger.LogInformation($"{sessionPrefix} Enrollment-end analysis: {newRuleResults.Count} issue(s) detected"); + } + } + catch (Exception ruleEx) + { + _logger.LogWarning(ruleEx, $"{sessionPrefix} Enrollment-end analysis failed (non-fatal)"); } - } - catch (Exception ruleEx) - { - _logger.LogWarning(ruleEx, $"{sessionPrefix} Rule engine evaluation failed (non-fatal)"); } // Retrieve updated session data to include in SignalR messages diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs index 31ee71a4..3d631d80 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs @@ -118,29 +118,57 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Seeds built-in analyze rules if not already done + /// Seeds built-in analyze rules if not already done. + /// Also updates existing built-in rules when code definitions change. /// private async Task EnsureBuiltInRulesSeededAsync() { if (_seeded) return; var existingRules = await _storageService.GetAnalyzeRulesAsync("global"); - if (existingRules.Count > 0) - { - _seeded = true; - return; - } - - _logger.LogInformation("Seeding built-in analyze rules..."); - var builtInRules = BuiltInAnalyzeRules.GetAll(); - foreach (var rule in builtInRules) + + if (existingRules.Count == 0) { - await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + _logger.LogInformation("Seeding built-in analyze rules..."); + foreach (var rule in builtInRules) + { + await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + } + _logger.LogInformation($"Seeded {builtInRules.Count} built-in analyze rules"); + } + else + { + // Update existing built-in rules to pick up code changes and add new rules + var existingLookup = existingRules.ToDictionary(r => r.RuleId, r => r); + var updated = 0; + + foreach (var rule in builtInRules) + { + if (existingLookup.TryGetValue(rule.RuleId, out var existing)) + { + if (existing.Title != rule.Title || existing.Description != rule.Description + || existing.Severity != rule.Severity || existing.Trigger != rule.Trigger) + { + await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + updated++; + } + } + else + { + // New built-in rule added in code (e.g. correlation rules) + await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + updated++; + } + } + + if (updated > 0) + { + _logger.LogInformation($"Updated {updated} built-in analyze rules from code definitions"); + } } _seeded = true; - _logger.LogInformation($"Seeded {builtInRules.Count} built-in analyze rules"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs b/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs index 3229a8c1..c416a81f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs @@ -44,7 +44,14 @@ namespace AutopilotMonitor.Functions.Services // ===== DEVICE RULES ===== CreateDeviceTpmNotReadyRule(), CreateDeviceBitlockerEscrowRule(), - CreateDeviceSecureBootRule() + CreateDeviceSecureBootRule(), + + // ===== CORRELATION RULES (cross-event analysis) ===== + CreateCorrelationProxyAuthRule(), + CreateCorrelationSslCertFailureRule(), + CreateCorrelationNetworkCausedInstallFailureRule(), + CreateCorrelationDiskSpaceCausedInstallFailureRule(), + CreateCorrelationTpmIdentityFailureRule() }; } @@ -525,5 +532,209 @@ namespace AutopilotMonitor.Functions.Services }, Tags = new[] { "device", "secureboot" } }; + + // ===== CORRELATION RULES ===== + // These combine signals from multiple event types for root-cause analysis + + private static AnalyzeRule CreateCorrelationProxyAuthRule() => new AnalyzeRule + { + RuleId = "ANALYZE-CORR-001", + Title = "Proxy Authentication Blocking Enrollment", + Description = "Correlates proxy configuration, HTTP 407 errors, and enrollment stall to confirm proxy authentication is the root cause.", + Severity = "critical", + Category = "network", + Trigger = "correlation", + BaseConfidence = 60, + Conditions = new List + { + new RuleCondition { Signal = "proxy_configured", Source = "event_type", EventType = "gather_proxy_settings", DataField = "ProxyEnable", Operator = "equals", Value = "1", Required = true }, + new RuleCondition { Signal = "auth_error", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "407|proxy.*auth|ProxyAuthenticationRequired", Required = true }, + new RuleCondition { Signal = "enrollment_failed", Source = "event_type", EventType = "enrollment_failed", Operator = "exists", Value = "" } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 20 }, + new ConfidenceFactor { Signal = "long_network_phase", Condition = "phase_duration > 180", Weight = 15 } + }, + ConfidenceThreshold = 50, + Explanation = "**Root cause confirmed:** The device is behind an authenticated proxy (ProxyEnable=1) and received HTTP 407 errors during enrollment. The SYSTEM account cannot authenticate with the proxy, causing enrollment to fail.\n\nThis is a common issue in corporate environments with SSL-inspecting proxies.", + Remediation = new List + { + new RemediationStep { Title = "Configure proxy bypass for Microsoft endpoints", Steps = new List { + "Add *.manage.microsoft.com to proxy bypass list", + "Add *.microsoftonline.com to proxy bypass list", + "Add enterpriseregistration.windows.net to bypass list", + "Add *.delivery.mp.microsoft.com to bypass list (app downloads)" + }}, + new RemediationStep { Title = "Use PAC file with exceptions", Steps = new List { + "Create/update PAC file to bypass proxy for Microsoft endpoints", + "Deploy PAC file via GPO or DHCP Option 252", + "Ensure PAC file is accessible during OOBE (before user login)" + }} + }, + RelatedDocs = new List + { + new RelatedDoc { Title = "Autopilot networking requirements", Url = "https://learn.microsoft.com/en-us/mem/autopilot/networking-requirements" }, + new RelatedDoc { Title = "Intune network endpoints", Url = "https://learn.microsoft.com/en-us/mem/intune/fundamentals/intune-endpoints" } + }, + Tags = new[] { "correlation", "network", "proxy", "authentication", "root-cause" } + }; + + private static AnalyzeRule CreateCorrelationSslCertFailureRule() => new AnalyzeRule + { + RuleId = "ANALYZE-CORR-002", + Title = "SSL Inspection Breaking Certificate Enrollment", + Description = "Correlates SSL inspection detection with certificate validation errors and enrollment failure to confirm SSL inspection as root cause.", + Severity = "critical", + Category = "network", + Trigger = "correlation", + BaseConfidence = 65, + Conditions = new List + { + new RuleCondition { Signal = "ssl_inspection", Source = "event_type", EventType = "cert_validation", DataField = "ssl_inspection_detected", Operator = "equals", Value = "true", Required = true }, + new RuleCondition { Signal = "cert_error", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "certificate|SSL|TLS|trust|chain|CERT_E_|0x800b", Required = true }, + new RuleCondition { Signal = "enrollment_failed", Source = "event_type", EventType = "enrollment_failed", Operator = "exists", Value = "" } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 20 }, + new ConfidenceFactor { Signal = "missing_mdm_cert", Condition = "exists", Weight = 15 } + }, + ConfidenceThreshold = 50, + Explanation = "**Root cause confirmed:** SSL/TLS inspection (MITM proxy) is intercepting connections to Microsoft enrollment endpoints. The intercepted certificates fail validation, breaking the MDM enrollment certificate chain.\n\nThe SSL inspection device replaces Microsoft's certificates with its own, which the enrollment client does not trust.", + Remediation = new List + { + new RemediationStep { Title = "Bypass SSL inspection for Microsoft endpoints", Steps = new List { + "Add *.manage.microsoft.com to SSL inspection bypass list", + "Add *.microsoftonline.com to bypass list", + "Add *.windows.net to bypass list", + "Add *.microsoft.com to bypass list for broader coverage" + }}, + new RemediationStep { Title = "Deploy inspection CA certificate", Steps = new List { + "Export the SSL inspection appliance's root CA certificate", + "Deploy it to devices via Autopilot deployment profile or OEM provisioning", + "Note: This is a workaround; bypass is the recommended approach" + }} + }, + Tags = new[] { "correlation", "network", "ssl", "certificate", "root-cause" } + }; + + private static AnalyzeRule CreateCorrelationNetworkCausedInstallFailureRule() => new AnalyzeRule + { + RuleId = "ANALYZE-CORR-003", + Title = "Slow Network Causing App Installation Failure", + Description = "Correlates slow download speeds with app installation failures while disk space is adequate, confirming network as root cause (not disk).", + Severity = "high", + Category = "apps", + Trigger = "correlation", + BaseConfidence = 55, + Conditions = new List + { + new RuleCondition { Signal = "app_failed", Source = "event_type", EventType = "app_install_failed", Operator = "exists", Value = "", Required = true }, + new RuleCondition { Signal = "download_events", Source = "event_type", EventType = "download_progress", Operator = "exists", Value = "", Required = true }, + new RuleCondition { Signal = "disk_ok", Source = "event_type", EventType = "performance_snapshot", DataField = "disk_free_gb", Operator = "gt", Value = "10" } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "disk_ok", Condition = "exists", Weight = 15 }, + new ConfidenceFactor { Signal = "multiple_failures", Condition = "count >= 2", Weight = 15 }, + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 15 } + }, + ConfidenceThreshold = 50, + Explanation = "App installation failed while the device has sufficient disk space (>10 GB free). Download progress events indicate network-related issues are the likely root cause.\n\nThis rules out disk space as a factor and points to network bandwidth, proxy throttling, or CDN connectivity issues.", + Remediation = new List + { + new RemediationStep { Title = "Investigate network throughput", Steps = new List { + "Check bandwidth to *.delivery.mp.microsoft.com (CDN)", + "Verify proxy is not throttling large downloads", + "Consider enabling Delivery Optimization for peer-to-peer caching" + }}, + new RemediationStep { Title = "Reduce download load", Steps = new List { + "Stagger app deployments to reduce concurrent downloads", + "Pre-cache large apps using Delivery Optimization or ConfigMgr", + "Increase ESP timeout if downloads are slow but completing" + }} + }, + Tags = new[] { "correlation", "apps", "network", "download", "root-cause" } + }; + + private static AnalyzeRule CreateCorrelationDiskSpaceCausedInstallFailureRule() => new AnalyzeRule + { + RuleId = "ANALYZE-CORR-004", + Title = "Disk Space Exhaustion Caused Installation Failure", + Description = "Correlates declining disk free space with app installation failure, confirming insufficient storage as root cause.", + Severity = "critical", + Category = "apps", + Trigger = "correlation", + BaseConfidence = 65, + Conditions = new List + { + new RuleCondition { Signal = "low_disk", Source = "event_type", EventType = "performance_snapshot", DataField = "disk_free_gb", Operator = "lt", Value = "5", Required = true }, + new RuleCondition { Signal = "install_error", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "disk space|storage|not enough space|0x80070070|0x80070008|ERROR_DISK_FULL", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "app_failed", Condition = "exists", Weight = 20 }, + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 10 } + }, + ConfidenceThreshold = 50, + Explanation = "**Root cause confirmed:** Performance monitoring shows disk free space dropped below 5 GB during enrollment, and error events contain disk-space-related error codes. The device ran out of storage during app installation.\n\nThis is common when deploying many large Win32 apps to devices with small drives (64 GB or 128 GB).", + Remediation = new List + { + new RemediationStep { Title = "Reduce disk consumption during enrollment", Steps = new List { + "Review total size of all apps deployed during ESP", + "Move non-critical apps to post-ESP deployment", + "Use MSIX or Store apps instead of Win32 where possible (smaller footprint)", + "Clean up IME cache: C:\\Windows\\IMECache" + }}, + new RemediationStep { Title = "Use devices with larger drives", Steps = new List { + "Minimum 128 GB recommended for standard deployments", + "256 GB recommended when deploying large app suites (Office, Visual Studio, etc.)" + }} + }, + Tags = new[] { "correlation", "apps", "disk-space", "performance", "root-cause" } + }; + + private static AnalyzeRule CreateCorrelationTpmIdentityFailureRule() => new AnalyzeRule + { + RuleId = "ANALYZE-CORR-005", + Title = "TPM Issue Blocking Identity Enrollment", + Description = "Correlates TPM not-ready status with identity phase failure and missing device certificates to confirm TPM as root cause.", + Severity = "critical", + Category = "device", + Trigger = "correlation", + BaseConfidence = 60, + Conditions = new List + { + new RuleCondition { Signal = "tpm_issue", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "TPM|Trusted Platform|0x80284|0x80290|attestation", Required = true }, + new RuleCondition { Signal = "identity_stalled", Source = "phase_duration", EventType = "phase_changed", DataField = "currentPhase", Operator = "equals", Value = "Identity", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "long_identity", Condition = "phase_duration > 300", Weight = 20 }, + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 15 } + }, + ConfidenceThreshold = 50, + Explanation = "**Root cause confirmed:** TPM-related errors occurred during the identity phase, which also took longer than expected. The TPM is either not ready, not provisioned, or malfunctioning, preventing Azure AD join and device attestation.\n\nTPM issues block the entire enrollment chain since device identity cannot be established.", + Remediation = new List + { + new RemediationStep { Title = "Fix TPM", Steps = new List { + "Enter BIOS/UEFI and verify TPM is enabled", + "Update TPM firmware to latest version", + "Try clearing TPM: tpm.msc > Clear TPM (WARNING: removes keys)", + "Verify TPM version is 2.0 (required for Autopilot)" + }}, + new RemediationStep { Title = "Pre-provision TPM", Steps = new List { + "Run 'Initialize-Tpm' in elevated PowerShell before enrollment", + "For new devices, ensure OEM has provisioned TPM during manufacturing", + "Check for known TPM firmware issues with your hardware vendor" + }} + }, + RelatedDocs = new List + { + new RelatedDoc { Title = "Autopilot hardware requirements", Url = "https://learn.microsoft.com/en-us/mem/autopilot/software-requirements" } + }, + Tags = new[] { "correlation", "device", "tpm", "identity", "root-cause" } + }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index dcf78210..464311bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -5,8 +5,9 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// - /// Evaluates analyze rules against session events to detect issues - /// Runs during event ingestion for real-time analysis + /// Evaluates analyze rules against session events to detect issues. + /// Runs once at enrollment end or on-demand via "Analyze Now" button. + /// All rules (single + correlation) are evaluated in a single pass over all events. /// public class RuleEngine { @@ -22,25 +23,30 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Evaluates all active analyze rules against the session's events - /// Returns new rule results that should be stored + /// Evaluates ALL active analyze rules against the full session event stream. + /// Called once at enrollment end or on-demand. Fetches events internally. /// - public async Task> EvaluateRulesAsync( - string tenantId, - string sessionId, - List newEvents, - List allSessionEvents) + public async Task> AnalyzeSessionAsync(string tenantId, string sessionId) { var results = new List(); try { var activeRules = await _ruleService.GetActiveRulesForTenantAsync(tenantId); + var allEvents = await _storageService.GetSessionEventsAsync(tenantId, sessionId); + + if (allEvents.Count == 0) + { + _logger.LogInformation($"No events found for session {sessionId}, skipping analysis"); + return results; + } // Get existing rule results to avoid duplicates var existingResults = await _storageService.GetRuleResultsAsync(tenantId, sessionId); var existingRuleIds = new HashSet(existingResults.Select(r => r.RuleId)); + _logger.LogInformation($"Analyzing session {sessionId}: {allEvents.Count} events, {activeRules.Count} rules ({existingRuleIds.Count} already evaluated)"); + foreach (var rule in activeRules) { try @@ -49,17 +55,13 @@ namespace AutopilotMonitor.Functions.Services if (existingRuleIds.Contains(rule.RuleId)) continue; - // Check if any new events are relevant to this rule - if (!HasRelevantEvents(rule, newEvents)) - continue; - - var result = EvaluateRule(rule, allSessionEvents); + var result = EvaluateRule(rule, allEvents); if (result != null) { result.SessionId = sessionId; result.TenantId = tenantId; results.Add(result); - _logger.LogInformation($"Rule {rule.RuleId} fired for session {sessionId} with confidence {result.ConfidenceScore}%"); + _logger.LogInformation($"Rule {rule.RuleId} ({rule.Trigger}) fired for session {sessionId} with confidence {result.ConfidenceScore}%"); } } catch (Exception ex) @@ -70,40 +72,12 @@ namespace AutopilotMonitor.Functions.Services } catch (Exception ex) { - _logger.LogError(ex, "Error evaluating rules"); + _logger.LogError(ex, "Error analyzing session"); } return results; } - /// - /// Checks if any new events are potentially relevant to a rule - /// Quick pre-filter to avoid expensive evaluation for irrelevant events - /// - private bool HasRelevantEvents(AnalyzeRule rule, List newEvents) - { - foreach (var condition in rule.Conditions) - { - if (string.IsNullOrEmpty(condition.EventType)) - continue; - - foreach (var evt in newEvents) - { - if (MatchesEventType(evt, condition.EventType)) - return true; - } - } - - // Also check for phase-related rules if we have phase events - if (rule.Conditions.Any(c => c.Source == "phase_duration")) - { - if (newEvents.Any(e => e.EventType == "phase_changed")) - return true; - } - - return false; - } - /// /// Evaluates a single rule against the full session event stream /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs index 13c84c0a..956e16a7 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs @@ -59,6 +59,12 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsCommunity { get; set; } = false; + /// + /// Rule trigger type: "single" (matches individual events) or "correlation" (combines multiple event types) + /// Both types run at the same time during analysis - this field is organizational/descriptive + /// + public string Trigger { get; set; } = "single"; + // ===== MATCHING CONDITIONS ===== /// 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 71860fca..fe57e4df 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -290,15 +290,16 @@ export default function SessionDetailPage() { } }; - const fetchAnalysisResults = async () => { + const fetchAnalysisResults = async (reanalyze = false) => { const effectiveTenantId = sessionTenantId || tenantId; try { setLoadingAnalysis(true); const token = await getAccessToken(); if (!token) return; + const reanalyzeParam = reanalyze ? '&reanalyze=true' : ''; const response = await fetch( - `${API_BASE_URL}/api/sessions/${sessionId}/analysis?tenantId=${effectiveTenantId}`, + `${API_BASE_URL}/api/sessions/${sessionId}/analysis?tenantId=${effectiveTenantId}${reanalyzeParam}`, { headers: { 'Authorization': `Bearer ${token}` } } ); if (response.ok) { @@ -504,15 +505,18 @@ export default function SessionDetailPage() { )} {/* Analysis Results */} - {(analysisResults.length > 0 || loadingAnalysis) && ( -
-
-
- - - -

Analysis Results

-
+
+
+
+ + + +

Analysis Results

+ {analysisResults.length > 0 && ( + ({analysisResults.length} issues) + )} +
+
{analysisResults.length > 0 && (
@@ -526,19 +530,46 @@ export default function SessionDetailPage() {
)} +
- - {loadingAnalysis ? ( -
Loading analysis...
- ) : ( -
- {analysisResults.map((result) => ( - - ))} -
- )}
- )} + + {loadingAnalysis && analysisResults.length === 0 ? ( +
Running analysis...
+ ) : analysisResults.length === 0 ? ( +
+ No issues detected yet. Click "Analyze Now" to run analysis on the current events, or wait for enrollment to complete for automatic analysis. +
+ ) : ( +
+ {analysisResults.map((result) => ( + + ))} +
+ )} +
{/* Performance Metrics (from performance_snapshot events) */}